mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-09 19:09:49 -04:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1462f9caf3 | |||
| ebaad55c8c | |||
| f5c770d65e |
@@ -1178,25 +1178,27 @@ export function Prompt(props: PromptProps) {
|
|||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
|
const parts = [
|
||||||
|
...editorParts,
|
||||||
|
{
|
||||||
|
id: PartID.ascending(),
|
||||||
|
type: "text" as const,
|
||||||
|
text: inputText,
|
||||||
|
},
|
||||||
|
...nonTextParts.map(assign),
|
||||||
|
]
|
||||||
|
const request = {
|
||||||
|
sessionID,
|
||||||
|
messageID,
|
||||||
|
agent: agent.name,
|
||||||
|
model: selectedModel,
|
||||||
|
variant,
|
||||||
|
parts,
|
||||||
|
}
|
||||||
|
sync.session.addOptimisticPrompt(request)
|
||||||
sdk.client.session
|
sdk.client.session
|
||||||
.prompt({
|
.prompt(request)
|
||||||
sessionID,
|
.catch(() => sync.session.removeOptimisticPrompt(request.sessionID, request.messageID))
|
||||||
...selectedModel,
|
|
||||||
messageID,
|
|
||||||
agent: agent.name,
|
|
||||||
model: selectedModel,
|
|
||||||
variant,
|
|
||||||
parts: [
|
|
||||||
...editorParts,
|
|
||||||
{
|
|
||||||
id: PartID.ascending(),
|
|
||||||
type: "text",
|
|
||||||
text: inputText,
|
|
||||||
},
|
|
||||||
...nonTextParts.map(assign),
|
|
||||||
],
|
|
||||||
})
|
|
||||||
.catch(() => {})
|
|
||||||
if (editorParts.length > 0) editor.markSelectionSent()
|
if (editorParts.length > 0) editor.markSelectionSent()
|
||||||
}
|
}
|
||||||
history.append({
|
history.append({
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import type { AgentPartInput, FilePartInput, Message, Part, SubtaskPartInput, TextPartInput } from "@opencode-ai/sdk/v2"
|
||||||
|
import { Binary } from "@opencode-ai/core/util/binary"
|
||||||
|
|
||||||
|
export type OptimisticPromptPart = (TextPartInput | FilePartInput | AgentPartInput | SubtaskPartInput) & { id: string }
|
||||||
|
|
||||||
|
export function optimisticParts(input: { sessionID: string; messageID: string; parts: OptimisticPromptPart[] }) {
|
||||||
|
return input.parts.map((part): Part => {
|
||||||
|
const withIDs = {
|
||||||
|
...part,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
messageID: input.messageID,
|
||||||
|
}
|
||||||
|
if (withIDs.type === "file") return { ...withIDs, url: "" }
|
||||||
|
return withIDs
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeFetchedMessages(input: {
|
||||||
|
currentMessages: Message[]
|
||||||
|
currentParts: Record<string, Part[] | undefined>
|
||||||
|
fetched: { info: Message; parts: Part[] }[]
|
||||||
|
optimisticMessages: ReadonlySet<string>
|
||||||
|
}) {
|
||||||
|
const fetchedIDs = new Set(input.fetched.map((message) => message.info.id))
|
||||||
|
const messages = input.fetched.map((message) => message.info)
|
||||||
|
const parts = new Map<string, Part[]>()
|
||||||
|
const resolved = new Set<string>()
|
||||||
|
|
||||||
|
for (const message of input.currentMessages) {
|
||||||
|
if (input.optimisticMessages.has(message.id) && !fetchedIDs.has(message.id)) {
|
||||||
|
Binary.insert(messages, message, (item) => item.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const message of input.fetched) {
|
||||||
|
if (message.parts.length > 0) {
|
||||||
|
resolved.add(message.info.id)
|
||||||
|
parts.set(message.info.id, message.parts)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (input.optimisticMessages.has(message.info.id)) {
|
||||||
|
const current = input.currentParts[message.info.id]
|
||||||
|
if (current) parts.set(message.info.id, current)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts.set(message.info.id, message.parts)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { messages, parts, resolved }
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ import { emptyConsoleState, type ConsoleState } from "@/config/console-state"
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import { useKV } from "./kv"
|
import { useKV } from "./kv"
|
||||||
import { aggregateFailures } from "./aggregate-failures"
|
import { aggregateFailures } from "./aggregate-failures"
|
||||||
|
import { mergeFetchedMessages, optimisticParts, type OptimisticPromptPart } from "./sync-optimistic"
|
||||||
|
|
||||||
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||||
name: "Sync",
|
name: "Sync",
|
||||||
@@ -113,6 +114,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
const kv = useKV()
|
const kv = useKV()
|
||||||
|
|
||||||
const fullSyncedSessions = new Set<string>()
|
const fullSyncedSessions = new Set<string>()
|
||||||
|
const optimisticMessages = new Set<string>()
|
||||||
|
|
||||||
function sessionListQuery(): { scope?: "project"; path?: string } {
|
function sessionListQuery(): { scope?: "project"; path?: string } {
|
||||||
if (!kv.get("session_directory_filter_enabled", true)) return { scope: "project" }
|
if (!kv.get("session_directory_filter_enabled", true)) return { scope: "project" }
|
||||||
@@ -219,6 +221,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
break
|
break
|
||||||
|
|
||||||
case "session.deleted": {
|
case "session.deleted": {
|
||||||
|
for (const message of store.message[event.properties.info.id] ?? []) optimisticMessages.delete(message.id)
|
||||||
const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
|
const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
|
||||||
if (result.found) {
|
if (result.found) {
|
||||||
setStore(
|
setStore(
|
||||||
@@ -290,6 +293,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "message.removed": {
|
case "message.removed": {
|
||||||
|
optimisticMessages.delete(event.properties.messageID)
|
||||||
const messages = store.message[event.properties.sessionID]
|
const messages = store.message[event.properties.sessionID]
|
||||||
const result = Binary.search(messages, event.properties.messageID, (m) => m.id)
|
const result = Binary.search(messages, event.properties.messageID, (m) => m.id)
|
||||||
if (result.found) {
|
if (result.found) {
|
||||||
@@ -304,6 +308,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "message.part.updated": {
|
case "message.part.updated": {
|
||||||
|
optimisticMessages.delete(event.properties.part.messageID)
|
||||||
const parts = store.part[event.properties.part.messageID]
|
const parts = store.part[event.properties.part.messageID]
|
||||||
if (!parts) {
|
if (!parts) {
|
||||||
setStore("part", event.properties.part.messageID, [event.properties.part])
|
setStore("part", event.properties.part.messageID, [event.properties.part])
|
||||||
@@ -518,6 +523,66 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
if (last.role === "user") return "working"
|
if (last.role === "user") return "working"
|
||||||
return last.time.completed ? "idle" : "working"
|
return last.time.completed ? "idle" : "working"
|
||||||
},
|
},
|
||||||
|
addOptimisticPrompt(input: {
|
||||||
|
sessionID: string
|
||||||
|
messageID: string
|
||||||
|
agent: string
|
||||||
|
model: { providerID: string; modelID: string }
|
||||||
|
variant?: string
|
||||||
|
parts: OptimisticPromptPart[]
|
||||||
|
}) {
|
||||||
|
optimisticMessages.add(input.messageID)
|
||||||
|
const messages = store.message[input.sessionID]
|
||||||
|
const match = messages ? Binary.search(messages, input.messageID, (m) => m.id) : undefined
|
||||||
|
const info: Message = {
|
||||||
|
id: input.messageID,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
role: "user",
|
||||||
|
time: { created: Date.now() },
|
||||||
|
agent: input.agent,
|
||||||
|
model: {
|
||||||
|
providerID: input.model.providerID,
|
||||||
|
modelID: input.model.modelID,
|
||||||
|
...(input.variant ? { variant: input.variant } : {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
batch(() => {
|
||||||
|
if (!messages) {
|
||||||
|
setStore("message", input.sessionID, [info])
|
||||||
|
} else if (!match?.found) {
|
||||||
|
setStore(
|
||||||
|
"message",
|
||||||
|
input.sessionID,
|
||||||
|
produce((draft) => {
|
||||||
|
Binary.insert(draft, info, (message) => message.id)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
setStore("part", input.messageID, reconcile(optimisticParts(input)))
|
||||||
|
})
|
||||||
|
},
|
||||||
|
removeOptimisticPrompt(sessionID: string, messageID: string) {
|
||||||
|
if (!optimisticMessages.delete(messageID)) return
|
||||||
|
const messages = store.message[sessionID]
|
||||||
|
const match = messages ? Binary.search(messages, messageID, (m) => m.id) : undefined
|
||||||
|
batch(() => {
|
||||||
|
if (match?.found) {
|
||||||
|
setStore(
|
||||||
|
"message",
|
||||||
|
sessionID,
|
||||||
|
produce((draft) => {
|
||||||
|
draft.splice(match.index, 1)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
setStore(
|
||||||
|
"part",
|
||||||
|
produce((draft) => {
|
||||||
|
delete draft[messageID]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
},
|
||||||
async sync(sessionID: string) {
|
async sync(sessionID: string) {
|
||||||
if (fullSyncedSessions.has(sessionID)) return
|
if (fullSyncedSessions.has(sessionID)) return
|
||||||
const [session, messages, todo, diff] = await Promise.all([
|
const [session, messages, todo, diff] = await Promise.all([
|
||||||
@@ -529,15 +594,22 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
setStore(
|
setStore(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
const match = Binary.search(draft.session, sessionID, (s) => s.id)
|
const match = Binary.search(draft.session, sessionID, (s) => s.id)
|
||||||
|
const merged = mergeFetchedMessages({
|
||||||
|
currentMessages: draft.message[sessionID] ?? [],
|
||||||
|
currentParts: draft.part,
|
||||||
|
fetched: messages.data ?? [],
|
||||||
|
optimisticMessages,
|
||||||
|
})
|
||||||
if (match.found) draft.session[match.index] = session.data!
|
if (match.found) draft.session[match.index] = session.data!
|
||||||
if (!match.found) draft.session.splice(match.index, 0, session.data!)
|
if (!match.found) draft.session.splice(match.index, 0, session.data!)
|
||||||
draft.todo[sessionID] = todo.data ?? []
|
draft.todo[sessionID] = todo.data ?? []
|
||||||
const infos: (typeof draft.message)[string] = []
|
draft.message[sessionID] = merged.messages
|
||||||
for (const message of messages.data ?? []) {
|
for (const messageID of merged.resolved) {
|
||||||
infos.push(message.info)
|
optimisticMessages.delete(messageID)
|
||||||
draft.part[message.info.id] = message.parts
|
}
|
||||||
|
for (const [messageID, parts] of merged.parts) {
|
||||||
|
draft.part[messageID] = parts
|
||||||
}
|
}
|
||||||
draft.message[sessionID] = infos
|
|
||||||
draft.session_diff[sessionID] = diff.data ?? []
|
draft.session_diff[sessionID] = diff.data ?? []
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import type { Message, Part } from "@opencode-ai/sdk/v2"
|
||||||
|
import { mergeFetchedMessages, optimisticParts } from "@/cli/cmd/tui/context/sync-optimistic"
|
||||||
|
|
||||||
|
function user(id: string): Message {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
sessionID: "ses_test",
|
||||||
|
role: "user",
|
||||||
|
time: { created: 1 },
|
||||||
|
agent: "build",
|
||||||
|
model: { providerID: "test", modelID: "model" },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(messageID: string, text: string): Part {
|
||||||
|
return {
|
||||||
|
id: `part_${messageID}`,
|
||||||
|
sessionID: "ses_test",
|
||||||
|
messageID,
|
||||||
|
type: "text",
|
||||||
|
text,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("TUI optimistic prompt sync", () => {
|
||||||
|
test("keeps an optimistic message while session sync has not fetched it yet", () => {
|
||||||
|
const merged = mergeFetchedMessages({
|
||||||
|
currentMessages: [user("msg_2")],
|
||||||
|
currentParts: { msg_2: [text("msg_2", "optimistic")] },
|
||||||
|
fetched: [{ info: user("msg_1"), parts: [text("msg_1", "persisted")] }],
|
||||||
|
optimisticMessages: new Set(["msg_2"]),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(merged.messages.map((message) => message.id)).toEqual(["msg_1", "msg_2"])
|
||||||
|
expect(merged.parts.get("msg_1")?.map((part) => (part.type === "text" ? part.text : ""))).toEqual(["persisted"])
|
||||||
|
expect(merged.resolved.has("msg_2")).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("preserves optimistic parts when sync fetches the message before its parts", () => {
|
||||||
|
const merged = mergeFetchedMessages({
|
||||||
|
currentMessages: [user("msg_1")],
|
||||||
|
currentParts: { msg_1: [text("msg_1", "optimistic")] },
|
||||||
|
fetched: [{ info: user("msg_1"), parts: [] }],
|
||||||
|
optimisticMessages: new Set(["msg_1"]),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(merged.messages.map((message) => message.id)).toEqual(["msg_1"])
|
||||||
|
expect(merged.parts.get("msg_1")?.map((part) => (part.type === "text" ? part.text : ""))).toEqual(["optimistic"])
|
||||||
|
expect(merged.resolved.has("msg_1")).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("replaces optimistic parts once real fetched parts arrive", () => {
|
||||||
|
const merged = mergeFetchedMessages({
|
||||||
|
currentMessages: [user("msg_1")],
|
||||||
|
currentParts: { msg_1: [text("msg_1", "optimistic")] },
|
||||||
|
fetched: [{ info: user("msg_1"), parts: [text("msg_1", "persisted")] }],
|
||||||
|
optimisticMessages: new Set(["msg_1"]),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(merged.parts.get("msg_1")?.map((part) => (part.type === "text" ? part.text : ""))).toEqual(["persisted"])
|
||||||
|
expect(merged.resolved.has("msg_1")).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("strips file URLs from optimistic render parts", () => {
|
||||||
|
const parts = optimisticParts({
|
||||||
|
sessionID: "ses_test",
|
||||||
|
messageID: "msg_1",
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
id: "part_file",
|
||||||
|
type: "file",
|
||||||
|
mime: "image/png",
|
||||||
|
filename: "image.png",
|
||||||
|
url: "data:image/png;base64,large",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(parts).toEqual([
|
||||||
|
{
|
||||||
|
id: "part_file",
|
||||||
|
sessionID: "ses_test",
|
||||||
|
messageID: "msg_1",
|
||||||
|
type: "file",
|
||||||
|
mime: "image/png",
|
||||||
|
filename: "image.png",
|
||||||
|
url: "",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user