mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-09 19:09:49 -04:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 12c7dc1bfd | |||
| df56253799 | |||
| 87eb062238 |
@@ -100,6 +100,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
directory: process.cwd(),
|
directory: process.cwd(),
|
||||||
})
|
})
|
||||||
const messageIndex = new Map<string, Map<string, number>>()
|
const messageIndex = new Map<string, Map<string, number>>()
|
||||||
|
const appliedEventSeq = new Map<string, number>()
|
||||||
let connectionGeneration = 0
|
let connectionGeneration = 0
|
||||||
let statusChanges: Set<string> | undefined
|
let statusChanges: Set<string> | undefined
|
||||||
let bootstrapping: Promise<void> | undefined
|
let bootstrapping: Promise<void> | undefined
|
||||||
@@ -208,6 +209,11 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleEvent(event: V2Event) {
|
function handleEvent(event: V2Event) {
|
||||||
|
if ("durable" in event && event.durable)
|
||||||
|
appliedEventSeq.set(
|
||||||
|
event.durable.aggregateID,
|
||||||
|
Math.max(appliedEventSeq.get(event.durable.aggregateID) ?? -1, event.durable.seq),
|
||||||
|
)
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "session.created":
|
case "session.created":
|
||||||
void result.session.refresh(event.data.sessionID)
|
void result.session.refresh(event.data.sessionID)
|
||||||
@@ -382,7 +388,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
break
|
break
|
||||||
case "session.text.started":
|
case "session.text.started":
|
||||||
message.update(event.data.sessionID, (draft, index) => {
|
message.update(event.data.sessionID, (draft, index) => {
|
||||||
message.assistant(draft, index, event.data.assistantMessageID)?.content.push({
|
const assistant = message.assistant(draft, index, event.data.assistantMessageID)
|
||||||
|
if (!assistant || message.latestText(assistant, event.data.textID)) return
|
||||||
|
assistant.content.push({
|
||||||
type: "text",
|
type: "text",
|
||||||
id: event.data.textID,
|
id: event.data.textID,
|
||||||
text: "",
|
text: "",
|
||||||
@@ -409,7 +417,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
break
|
break
|
||||||
case "session.tool.input.started":
|
case "session.tool.input.started":
|
||||||
message.update(event.data.sessionID, (draft, index) => {
|
message.update(event.data.sessionID, (draft, index) => {
|
||||||
message.assistant(draft, index, event.data.assistantMessageID)?.content.push({
|
const assistant = message.assistant(draft, index, event.data.assistantMessageID)
|
||||||
|
if (!assistant || message.latestTool(assistant, event.data.callID)) return
|
||||||
|
assistant.content.push({
|
||||||
type: "tool",
|
type: "tool",
|
||||||
id: event.data.callID,
|
id: event.data.callID,
|
||||||
name: event.data.name,
|
name: event.data.name,
|
||||||
@@ -442,7 +452,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
message.assistant(draft, index, event.data.assistantMessageID),
|
message.assistant(draft, index, event.data.assistantMessageID),
|
||||||
event.data.callID,
|
event.data.callID,
|
||||||
)
|
)
|
||||||
if (!match) return
|
if (match?.state.status !== "pending") return
|
||||||
match.time.ran = event.created
|
match.time.ran = event.created
|
||||||
match.provider = event.data.provider
|
match.provider = event.data.provider
|
||||||
match.state = { status: "running", input: event.data.input, structured: {}, content: [] }
|
match.state = { status: "running", input: event.data.input, structured: {}, content: [] }
|
||||||
@@ -506,7 +516,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
break
|
break
|
||||||
case "session.reasoning.started":
|
case "session.reasoning.started":
|
||||||
message.update(event.data.sessionID, (draft, index) => {
|
message.update(event.data.sessionID, (draft, index) => {
|
||||||
message.assistant(draft, index, event.data.assistantMessageID)?.content.push({
|
const assistant = message.assistant(draft, index, event.data.assistantMessageID)
|
||||||
|
if (!assistant || message.latestReasoning(assistant, event.data.reasoningID)) return
|
||||||
|
assistant.content.push({
|
||||||
type: "reasoning",
|
type: "reasoning",
|
||||||
id: event.data.reasoningID,
|
id: event.data.reasoningID,
|
||||||
text: "",
|
text: "",
|
||||||
@@ -693,21 +705,24 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
return position === undefined ? undefined : messages?.[position]
|
return position === undefined ? undefined : messages?.[position]
|
||||||
},
|
},
|
||||||
async refresh(sessionID: string) {
|
async refresh(sessionID: string) {
|
||||||
|
const response = await sdk.api.message.list({ sessionID, limit: 200, order: "desc" })
|
||||||
|
const loaded = mutable(response.data).toReversed()
|
||||||
const live = [...(store.session.message[sessionID] ?? [])]
|
const live = [...(store.session.message[sessionID] ?? [])]
|
||||||
setStore("session", "message", sessionID, [])
|
|
||||||
messageIndex.set(sessionID, new Map())
|
|
||||||
const loaded = mutable(
|
|
||||||
(await sdk.api.message.list({ sessionID, limit: 200, order: "desc" })).data,
|
|
||||||
).toReversed()
|
|
||||||
const loadedIDs = new Set(loaded.map((message) => message.id))
|
const loadedIDs = new Set(loaded.map((message) => message.id))
|
||||||
const liveByID = new Map(live.map((message) => [message.id, message]))
|
const liveByID = new Map(live.map((message) => [message.id, message]))
|
||||||
|
const snapshotIsCurrent =
|
||||||
|
response.watermark !== undefined && response.watermark >= (appliedEventSeq.get(sessionID) ?? -1)
|
||||||
const messages = [
|
const messages = [
|
||||||
...loaded.map((message) => {
|
...loaded.map((message) => {
|
||||||
if (message.type === "user") return message
|
if (message.type === "user") return message
|
||||||
return liveByID.get(message.id) ?? message
|
if (snapshotIsCurrent) return message
|
||||||
|
const live = liveByID.get(message.id)
|
||||||
|
if (!live || ("completed" in message.time && message.time.completed !== undefined)) return message
|
||||||
|
return live
|
||||||
}),
|
}),
|
||||||
...live.filter((message) => !loadedIDs.has(message.id)),
|
...live.filter((message) => !loadedIDs.has(message.id)),
|
||||||
].toSorted((a, b) => a.time.created - b.time.created)
|
].toSorted((a, b) => a.time.created - b.time.created)
|
||||||
|
if (snapshotIsCurrent) appliedEventSeq.set(sessionID, response.watermark)
|
||||||
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, messages)
|
setStore("session", "message", sessionID, messages)
|
||||||
},
|
},
|
||||||
@@ -925,6 +940,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
if (details.type === "server.connected") {
|
if (details.type === "server.connected") {
|
||||||
refreshActive()
|
refreshActive()
|
||||||
void bootstrap()
|
void bootstrap()
|
||||||
|
for (const sessionID of Object.keys(store.session.message))
|
||||||
|
void result.session.message.refresh(sessionID).catch(() => undefined)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
handleEvent(details)
|
handleEvent(details)
|
||||||
|
|||||||
@@ -108,9 +108,241 @@ test("refreshes resources into reactive getters", async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("refresh reconciles messages by durable snapshot watermark", async () => {
|
||||||
|
const events = createEventStream()
|
||||||
|
const sessionID = "session-stale-tool"
|
||||||
|
const messageID = "message-stale-tool"
|
||||||
|
const liveMessageID = "message-live-tool"
|
||||||
|
let requests = 0
|
||||||
|
const calls = createFetch((url) => {
|
||||||
|
if (url.pathname === `/api/session/${sessionID}/message` && ++requests === 1)
|
||||||
|
return json({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: messageID,
|
||||||
|
type: "assistant",
|
||||||
|
agent: "build",
|
||||||
|
model: { id: "model", providerID: "provider" },
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "tool",
|
||||||
|
id: "call-write",
|
||||||
|
name: "write",
|
||||||
|
provider: { executed: false },
|
||||||
|
state: {
|
||||||
|
status: "error",
|
||||||
|
input: {},
|
||||||
|
content: [],
|
||||||
|
structured: {},
|
||||||
|
error: { type: "unknown", message: "Tool execution interrupted" },
|
||||||
|
},
|
||||||
|
time: { created: 1, completed: 3 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
finish: "error",
|
||||||
|
error: { type: "unknown", message: "Step interrupted" },
|
||||||
|
time: { created: 1, completed: 3 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
watermark: 2,
|
||||||
|
cursor: {},
|
||||||
|
})
|
||||||
|
if (url.pathname === `/api/session/${sessionID}/message`)
|
||||||
|
return json({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: liveMessageID,
|
||||||
|
type: "assistant",
|
||||||
|
agent: "build",
|
||||||
|
model: { id: "model", providerID: "provider" },
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "tool",
|
||||||
|
id: "call-read",
|
||||||
|
name: "read",
|
||||||
|
state: { status: "pending", input: "" },
|
||||||
|
time: { created: 4 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
time: { created: 3 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
watermark: 4,
|
||||||
|
cursor: {},
|
||||||
|
})
|
||||||
|
return undefined
|
||||||
|
}, events)
|
||||||
|
let data!: ReturnType<typeof useData>
|
||||||
|
|
||||||
|
function Probe() {
|
||||||
|
data = useData()
|
||||||
|
return <box />
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = await testRender(() => (
|
||||||
|
<TestTuiContexts>
|
||||||
|
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
|
||||||
|
<ProjectProvider>
|
||||||
|
<DataProvider>
|
||||||
|
<Probe />
|
||||||
|
</DataProvider>
|
||||||
|
</ProjectProvider>
|
||||||
|
</SDKProvider>
|
||||||
|
</TestTuiContexts>
|
||||||
|
))
|
||||||
|
|
||||||
|
try {
|
||||||
|
emitEvent(events, {
|
||||||
|
id: "evt_step_started_stale_tool",
|
||||||
|
created: 1,
|
||||||
|
type: "session.step.started",
|
||||||
|
durable: durable(sessionID),
|
||||||
|
data: {
|
||||||
|
sessionID,
|
||||||
|
assistantMessageID: messageID,
|
||||||
|
agent: "build",
|
||||||
|
model: { id: "model", providerID: "provider" },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
emitEvent(events, {
|
||||||
|
id: "evt_tool_started_stale_tool",
|
||||||
|
created: 2,
|
||||||
|
type: "session.tool.input.started",
|
||||||
|
durable: durable(sessionID, 1),
|
||||||
|
data: {
|
||||||
|
sessionID,
|
||||||
|
assistantMessageID: messageID,
|
||||||
|
callID: "call-write",
|
||||||
|
name: "write",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
emitEvent(events, {
|
||||||
|
id: "evt_step_failed_stale_tool",
|
||||||
|
created: 3,
|
||||||
|
type: "session.step.failed",
|
||||||
|
durable: durable(sessionID, 2),
|
||||||
|
data: {
|
||||||
|
sessionID,
|
||||||
|
assistantMessageID: messageID,
|
||||||
|
error: { type: "unknown", message: "Step interrupted" },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
emitEvent(events, {
|
||||||
|
id: "evt_prompt_after_stale_tool",
|
||||||
|
created: 4,
|
||||||
|
type: "session.prompt.admitted",
|
||||||
|
durable: durable(sessionID, 3),
|
||||||
|
data: {
|
||||||
|
sessionID,
|
||||||
|
inputID: "message-after-stale-tool",
|
||||||
|
prompt: { text: "Continue" },
|
||||||
|
delivery: "steer",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await wait(() => {
|
||||||
|
const message = data.session.message.get(sessionID, messageID)
|
||||||
|
return message?.type === "assistant" && message.time.completed === 3
|
||||||
|
})
|
||||||
|
await data.session.message.refresh(sessionID)
|
||||||
|
|
||||||
|
const message = data.session.message.get(sessionID, messageID)
|
||||||
|
expect(message?.type).toBe("assistant")
|
||||||
|
if (message?.type !== "assistant") return
|
||||||
|
expect(message.content[0]).toMatchObject({ type: "tool", state: { status: "error" } })
|
||||||
|
emitEvent(events, {
|
||||||
|
id: "evt_tool_started_stale_replay",
|
||||||
|
created: 2,
|
||||||
|
type: "session.tool.input.started",
|
||||||
|
durable: durable(sessionID, 1),
|
||||||
|
data: {
|
||||||
|
sessionID,
|
||||||
|
assistantMessageID: messageID,
|
||||||
|
callID: "call-write",
|
||||||
|
name: "write",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
emitEvent(events, {
|
||||||
|
id: "evt_tool_called_stale_replay",
|
||||||
|
created: 2,
|
||||||
|
type: "session.tool.called",
|
||||||
|
durable: durable(sessionID, 2),
|
||||||
|
data: {
|
||||||
|
sessionID,
|
||||||
|
assistantMessageID: messageID,
|
||||||
|
callID: "call-write",
|
||||||
|
tool: "write",
|
||||||
|
input: { path: "README.md" },
|
||||||
|
provider: { executed: false },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await Bun.sleep(10)
|
||||||
|
const replayed = data.session.message.get(sessionID, messageID)
|
||||||
|
expect(replayed?.type).toBe("assistant")
|
||||||
|
if (replayed?.type !== "assistant") return
|
||||||
|
expect(replayed.content).toHaveLength(1)
|
||||||
|
expect(replayed.content[0]).toMatchObject({ type: "tool", state: { status: "error" } })
|
||||||
|
|
||||||
|
emitEvent(events, {
|
||||||
|
id: "evt_step_started_live_tool",
|
||||||
|
created: 5,
|
||||||
|
type: "session.step.started",
|
||||||
|
durable: durable(sessionID, 4),
|
||||||
|
data: {
|
||||||
|
sessionID,
|
||||||
|
assistantMessageID: liveMessageID,
|
||||||
|
agent: "build",
|
||||||
|
model: { id: "model", providerID: "provider" },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
emitEvent(events, {
|
||||||
|
id: "evt_tool_started_live_tool",
|
||||||
|
created: 6,
|
||||||
|
type: "session.tool.input.started",
|
||||||
|
durable: durable(sessionID, 5),
|
||||||
|
data: {
|
||||||
|
sessionID,
|
||||||
|
assistantMessageID: liveMessageID,
|
||||||
|
callID: "call-read",
|
||||||
|
name: "read",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
emitEvent(events, {
|
||||||
|
id: "evt_tool_called_live_tool",
|
||||||
|
created: 7,
|
||||||
|
type: "session.tool.called",
|
||||||
|
durable: durable(sessionID, 6),
|
||||||
|
data: {
|
||||||
|
sessionID,
|
||||||
|
assistantMessageID: liveMessageID,
|
||||||
|
callID: "call-read",
|
||||||
|
tool: "read",
|
||||||
|
input: { path: "README.md" },
|
||||||
|
provider: { executed: false },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await wait(() => {
|
||||||
|
const message = data.session.message.get(sessionID, liveMessageID)
|
||||||
|
return (
|
||||||
|
message?.type === "assistant" &&
|
||||||
|
message.content[0]?.type === "tool" &&
|
||||||
|
message.content[0].state.status === "running"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
await data.session.message.refresh(sessionID)
|
||||||
|
|
||||||
|
const live = data.session.message.get(sessionID, liveMessageID)
|
||||||
|
expect(live?.type).toBe("assistant")
|
||||||
|
if (live?.type !== "assistant") return
|
||||||
|
expect(live.content[0]).toMatchObject({ type: "tool", state: { status: "running" } })
|
||||||
|
} finally {
|
||||||
|
app.renderer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test("reconnects the event stream and bootstraps fresh data", async () => {
|
test("reconnects the event stream and bootstraps fresh data", async () => {
|
||||||
const events = createEventStream()
|
const events = createEventStream()
|
||||||
const requests = { active: 0, event: 0, model: 0 }
|
const requests = { active: 0, event: 0, message: 0, model: 0 }
|
||||||
let resolveActive!: (response: Response) => void
|
let resolveActive!: (response: Response) => void
|
||||||
const calls = createFetch((url) => {
|
const calls = createFetch((url) => {
|
||||||
if (url.pathname === "/api/event") {
|
if (url.pathname === "/api/event") {
|
||||||
@@ -124,6 +356,40 @@ test("reconnects the event stream and bootstraps fresh data", async () => {
|
|||||||
resolveActive = resolve
|
resolveActive = resolve
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if (url.pathname === "/api/session/session-reconnect/message") {
|
||||||
|
requests.message++
|
||||||
|
return json({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: "message-reconnect",
|
||||||
|
type: "assistant",
|
||||||
|
agent: "build",
|
||||||
|
model: { id: "model", providerID: "provider" },
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "tool",
|
||||||
|
id: "call-reconnect",
|
||||||
|
name: "write",
|
||||||
|
provider: { executed: false },
|
||||||
|
state: {
|
||||||
|
status: "error",
|
||||||
|
input: {},
|
||||||
|
content: [],
|
||||||
|
structured: {},
|
||||||
|
error: { type: "unknown", message: "Tool execution interrupted" },
|
||||||
|
},
|
||||||
|
time: { created: 1, completed: 2 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
finish: "error",
|
||||||
|
error: { type: "unknown", message: "Step interrupted" },
|
||||||
|
time: { created: 0, completed: 2 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
watermark: 2,
|
||||||
|
cursor: {},
|
||||||
|
})
|
||||||
|
}
|
||||||
if (url.pathname !== "/api/model") return
|
if (url.pathname !== "/api/model") return
|
||||||
requests.model++
|
requests.model++
|
||||||
return json({
|
return json({
|
||||||
@@ -169,6 +435,30 @@ test("reconnects the event stream and bootstraps fresh data", async () => {
|
|||||||
await wait(() => data.session.status("session-stale") === "running")
|
await wait(() => data.session.status("session-stale") === "running")
|
||||||
expect(data.connection.status()).toBe("connected")
|
expect(data.connection.status()).toBe("connected")
|
||||||
expect(data.connection.attempt()).toBe(0)
|
expect(data.connection.attempt()).toBe(0)
|
||||||
|
emitEvent(events, {
|
||||||
|
id: "evt_step_started_before_reconnect",
|
||||||
|
created: 0,
|
||||||
|
type: "session.step.started",
|
||||||
|
durable: durable("session-reconnect"),
|
||||||
|
data: {
|
||||||
|
sessionID: "session-reconnect",
|
||||||
|
assistantMessageID: "message-reconnect",
|
||||||
|
agent: "build",
|
||||||
|
model: { id: "model", providerID: "provider" },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
emitEvent(events, {
|
||||||
|
id: "evt_tool_started_before_reconnect",
|
||||||
|
created: 1,
|
||||||
|
type: "session.tool.input.started",
|
||||||
|
durable: durable("session-reconnect", 1),
|
||||||
|
data: {
|
||||||
|
sessionID: "session-reconnect",
|
||||||
|
assistantMessageID: "message-reconnect",
|
||||||
|
callID: "call-reconnect",
|
||||||
|
name: "write",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
events.disconnect()
|
events.disconnect()
|
||||||
await wait(() => data.connection.status() === "connecting")
|
await wait(() => data.connection.status() === "connecting")
|
||||||
@@ -193,8 +483,17 @@ test("reconnects the event stream and bootstraps fresh data", async () => {
|
|||||||
|
|
||||||
await wait(() => data.location.model.list()?.[0]?.id === "model-2", 4000)
|
await wait(() => data.location.model.list()?.[0]?.id === "model-2", 4000)
|
||||||
await wait(() => data.session.status("session-stale") === "idle")
|
await wait(() => data.session.status("session-stale") === "idle")
|
||||||
|
await wait(() => {
|
||||||
|
const message = data.session.message.get("session-reconnect", "message-reconnect")
|
||||||
|
return (
|
||||||
|
message?.type === "assistant" &&
|
||||||
|
message.content[0]?.type === "tool" &&
|
||||||
|
message.content[0].state.status === "error"
|
||||||
|
)
|
||||||
|
})
|
||||||
expect(data.session.status("session-new")).toBe("running")
|
expect(data.session.status("session-new")).toBe("running")
|
||||||
expect(requests.event).toBe(2)
|
expect(requests.event).toBe(2)
|
||||||
|
expect(requests.message).toBe(1)
|
||||||
expect(data.connection.status()).toBe("connected")
|
expect(data.connection.status()).toBe("connected")
|
||||||
expect(data.connection.attempt()).toBe(0)
|
expect(data.connection.attempt()).toBe(0)
|
||||||
expect(data.connection.error()).toBeUndefined()
|
expect(data.connection.error()).toBeUndefined()
|
||||||
|
|||||||
Reference in New Issue
Block a user