chore(tui): merge v2 into form branch
This commit is contained in:
commit
be0cd93c8f
235 changed files with 5364 additions and 3885 deletions
|
|
@ -51,6 +51,7 @@
|
|||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/simulation": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
"@opentui/keymap": "catalog:",
|
||||
|
|
|
|||
|
|
@ -139,6 +139,7 @@ export type TuiInput = {
|
|||
client: OpencodeClient
|
||||
api: OpenCodeClient
|
||||
discover?: () => Promise<{ client: OpencodeClient; api: OpenCodeClient }>
|
||||
reload?: () => Promise<void>
|
||||
args: Args
|
||||
config: TuiConfig.Resolved
|
||||
onSnapshot?: () => Promise<string[]>
|
||||
|
|
@ -199,8 +200,8 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
|||
},
|
||||
} satisfies CliRendererConfig
|
||||
|
||||
if (process.env.OPENCODE_SIMULATION === "1" || process.env.OPENCODE_SIMULATION === "true") {
|
||||
const { Simulation } = await import("./simulation/simulation")
|
||||
if (!!process.env.OPENCODE_SIMULATION) {
|
||||
const { Simulation } = await import("@opencode-ai/simulation/frontend")
|
||||
return Simulation.createSimulation(options)
|
||||
}
|
||||
|
||||
|
|
@ -301,7 +302,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
|||
>
|
||||
<TuiConfigProvider config={input.config}>
|
||||
<PluginRuntimeProvider value={pluginRuntime}>
|
||||
<SDKProvider client={input.client} api={input.api} discover={input.discover}>
|
||||
<SDKProvider client={input.client} api={input.api} discover={input.discover} reload={input.reload}>
|
||||
<PermissionProvider>
|
||||
<ProjectProvider>
|
||||
<SyncProvider>
|
||||
|
|
@ -800,6 +801,26 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
|||
},
|
||||
category: "System",
|
||||
},
|
||||
...(sdk.reload
|
||||
? [
|
||||
{
|
||||
name: "server.reload",
|
||||
title: "Reload server",
|
||||
slashName: "reload",
|
||||
run: async () => {
|
||||
dialog.clear()
|
||||
toast.show({ variant: "info", message: "Reloading server...", duration: 30000 })
|
||||
// reload resolves once the replacement service is healthy; the
|
||||
// event stream reattaches through the reconnect loop.
|
||||
await sdk
|
||||
.reload!()
|
||||
.then(() => toast.show({ variant: "success", message: "Server reloaded" }))
|
||||
.catch(toast.error)
|
||||
},
|
||||
category: "System",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: "theme.switch",
|
||||
title: "Switch theme",
|
||||
|
|
|
|||
|
|
@ -1101,13 +1101,8 @@ export function Prompt(props: PromptProps) {
|
|||
|
||||
if (store.mode === "shell") {
|
||||
move.startSubmit()
|
||||
void sdk.client.session.shell({
|
||||
void sdk.client.v2.session.shell({
|
||||
sessionID,
|
||||
agent: agent.id,
|
||||
model: {
|
||||
providerID: selectedModel.providerID,
|
||||
modelID: selectedModel.modelID,
|
||||
},
|
||||
command: inputText,
|
||||
})
|
||||
setStore("mode", "normal")
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ export type DataSessionStatus = "idle" | "running"
|
|||
|
||||
export type FormInfo = (FormFormInfo | FormUrlInfo) & { readonly location?: LocationRef }
|
||||
|
||||
const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_")
|
||||
|
||||
type LocationData = {
|
||||
agent?: AgentV2Info[]
|
||||
command?: CommandV2Info[]
|
||||
|
|
@ -185,17 +187,21 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
const info = store.session.info[sessionID]
|
||||
if (!info) return
|
||||
const rootID = resolveRoot(sessionID)
|
||||
setStore("session", "family", produce((draft) => {
|
||||
if (sessionID !== rootID && draft[sessionID]) {
|
||||
const members = draft[rootID] ??= []
|
||||
for (const id of draft[sessionID]) {
|
||||
if (!members.includes(id)) members.push(id)
|
||||
setStore(
|
||||
"session",
|
||||
"family",
|
||||
produce((draft) => {
|
||||
if (sessionID !== rootID && draft[sessionID]) {
|
||||
const members = (draft[rootID] ??= [])
|
||||
for (const id of draft[sessionID]) {
|
||||
if (!members.includes(id)) members.push(id)
|
||||
}
|
||||
delete draft[sessionID]
|
||||
}
|
||||
delete draft[sessionID]
|
||||
}
|
||||
const family = draft[rootID] ??= []
|
||||
if (!family.includes(sessionID)) family.push(sessionID)
|
||||
}))
|
||||
const family = (draft[rootID] ??= [])
|
||||
if (!family.includes(sessionID)) family.push(sessionID)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function handleEvent(event: V2Event) {
|
||||
|
|
@ -218,124 +224,113 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
case "skill.updated":
|
||||
void result.location.skill.refresh(event.location)
|
||||
break
|
||||
case "session.next.agent.switched":
|
||||
case "agent.selected":
|
||||
if (store.session.info[event.data.sessionID])
|
||||
setStore("session", "info", event.data.sessionID, "agent", event.data.agent)
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.append(draft, index, {
|
||||
id: event.data.messageID,
|
||||
id: messageIDFromEvent(event.id),
|
||||
type: "agent-switched",
|
||||
agent: event.data.agent,
|
||||
time: { created: event.data.timestamp },
|
||||
time: { created: event.created },
|
||||
})
|
||||
})
|
||||
break
|
||||
case "session.next.model.switched":
|
||||
case "model.selected":
|
||||
if (store.session.info[event.data.sessionID])
|
||||
setStore("session", "info", event.data.sessionID, "model", event.data.model)
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.append(draft, index, {
|
||||
id: event.data.messageID,
|
||||
id: messageIDFromEvent(event.id),
|
||||
type: "model-switched",
|
||||
model: event.data.model,
|
||||
time: { created: event.data.timestamp },
|
||||
time: { created: event.created },
|
||||
})
|
||||
})
|
||||
break
|
||||
case "session.next.renamed":
|
||||
case "renamed":
|
||||
if (store.session.info[event.data.sessionID])
|
||||
setStore("session", "info", event.data.sessionID, "title", event.data.title)
|
||||
break
|
||||
case "session.next.prompted": {
|
||||
case "prompt.promoted": {
|
||||
setStore("session", "status", event.data.sessionID, "running")
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const position = index.get(event.data.messageID)
|
||||
const position = index.get(event.data.inputID)
|
||||
const existing = position === undefined ? undefined : draft[position]
|
||||
if (existing?.type === "user") {
|
||||
existing.text = event.data.prompt.text
|
||||
existing.files = event.data.prompt.files
|
||||
existing.agents = event.data.prompt.agents
|
||||
existing.time.created = event.data.timestamp
|
||||
existing.time.created = event.created
|
||||
if (existing.metadata?.queued === true) {
|
||||
delete existing.metadata.queued
|
||||
if (Object.keys(existing.metadata).length === 0) existing.metadata = undefined
|
||||
}
|
||||
return
|
||||
}
|
||||
message.append(draft, index, {
|
||||
id: event.data.messageID,
|
||||
type: "user",
|
||||
text: event.data.prompt.text,
|
||||
files: event.data.prompt.files,
|
||||
agents: event.data.prompt.agents,
|
||||
time: { created: event.data.timestamp },
|
||||
})
|
||||
})
|
||||
break
|
||||
}
|
||||
case "session.next.prompt.admitted":
|
||||
case "prompt.admitted":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.append(draft, index, {
|
||||
id: event.data.messageID,
|
||||
id: event.data.inputID,
|
||||
type: "user",
|
||||
text: event.data.prompt.text,
|
||||
files: event.data.prompt.files,
|
||||
agents: event.data.prompt.agents,
|
||||
metadata: { queued: true },
|
||||
time: { created: event.data.timestamp },
|
||||
time: { created: event.created },
|
||||
})
|
||||
})
|
||||
break
|
||||
case "session.next.context.updated":
|
||||
case "session.context.updated":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.append(draft, index, {
|
||||
id: event.data.messageID,
|
||||
id: messageIDFromEvent(event.id),
|
||||
type: "system",
|
||||
text: event.data.text,
|
||||
time: { created: event.data.timestamp },
|
||||
time: { created: event.created },
|
||||
})
|
||||
})
|
||||
break
|
||||
case "session.next.synthetic":
|
||||
case "synthetic":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.append(draft, index, {
|
||||
id: event.data.messageID,
|
||||
id: messageIDFromEvent(event.id),
|
||||
type: "synthetic",
|
||||
sessionID: event.data.sessionID,
|
||||
text: event.data.text,
|
||||
description: event.data.description,
|
||||
time: { created: event.data.timestamp },
|
||||
time: { created: event.created },
|
||||
})
|
||||
})
|
||||
break
|
||||
case "session.next.shell.started":
|
||||
case "shell.started":
|
||||
setStore("session", "status", event.data.sessionID, "running")
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.append(draft, index, {
|
||||
id: event.data.messageID,
|
||||
id: messageIDFromEvent(event.id),
|
||||
type: "shell",
|
||||
callID: event.data.callID,
|
||||
command: event.data.command,
|
||||
output: "",
|
||||
time: { created: event.data.timestamp },
|
||||
time: { created: event.created },
|
||||
})
|
||||
})
|
||||
break
|
||||
case "session.next.shell.ended":
|
||||
case "shell.ended":
|
||||
setStore("session", "status", event.data.sessionID, "idle")
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.activeShell(draft, event.data.callID)
|
||||
if (!match) return
|
||||
match.output = event.data.output
|
||||
match.time.completed = event.data.timestamp
|
||||
match.time.completed = event.created
|
||||
})
|
||||
break
|
||||
case "session.next.step.started":
|
||||
case "step.started":
|
||||
setStore("session", "status", event.data.sessionID, "running")
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
if (index.has(event.data.assistantMessageID)) return
|
||||
const currentAssistant = message.activeAssistant(draft)
|
||||
if (currentAssistant) currentAssistant.time.completed = event.data.timestamp
|
||||
if (currentAssistant) currentAssistant.time.completed = event.created
|
||||
message.append(draft, index, {
|
||||
id: event.data.assistantMessageID,
|
||||
type: "assistant",
|
||||
|
|
@ -343,16 +338,16 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
model: event.data.model,
|
||||
content: [],
|
||||
snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined,
|
||||
time: { created: event.data.timestamp },
|
||||
time: { created: event.created },
|
||||
})
|
||||
})
|
||||
break
|
||||
case "session.next.step.ended":
|
||||
case "step.ended":
|
||||
setStore("session", "status", event.data.sessionID, "running")
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID)
|
||||
if (!currentAssistant) return
|
||||
currentAssistant.time.completed = event.data.timestamp
|
||||
currentAssistant.time.completed = event.created
|
||||
currentAssistant.finish = event.data.finish
|
||||
currentAssistant.cost = event.data.cost
|
||||
currentAssistant.tokens = event.data.tokens
|
||||
|
|
@ -360,16 +355,16 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
currentAssistant.snapshot = { ...currentAssistant.snapshot, end: event.data.snapshot }
|
||||
})
|
||||
break
|
||||
case "session.next.step.failed":
|
||||
case "step.failed":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID)
|
||||
if (!currentAssistant) return
|
||||
currentAssistant.time.completed = event.data.timestamp
|
||||
currentAssistant.time.completed = event.created
|
||||
currentAssistant.finish = "error"
|
||||
currentAssistant.error = event.data.error
|
||||
})
|
||||
break
|
||||
case "session.next.text.started":
|
||||
case "text.started":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.assistant(draft, index, event.data.assistantMessageID)?.content.push({
|
||||
type: "text",
|
||||
|
|
@ -378,7 +373,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
})
|
||||
})
|
||||
break
|
||||
case "session.next.text.delta":
|
||||
case "text.delta":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestText(
|
||||
message.assistant(draft, index, event.data.assistantMessageID),
|
||||
|
|
@ -387,7 +382,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
if (match) match.text += event.data.delta
|
||||
})
|
||||
break
|
||||
case "session.next.text.ended":
|
||||
case "text.ended":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestText(
|
||||
message.assistant(draft, index, event.data.assistantMessageID),
|
||||
|
|
@ -396,18 +391,18 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
if (match) match.text = event.data.text
|
||||
})
|
||||
break
|
||||
case "session.next.tool.input.started":
|
||||
case "tool.input.started":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.assistant(draft, index, event.data.assistantMessageID)?.content.push({
|
||||
type: "tool",
|
||||
id: event.data.callID,
|
||||
name: event.data.name,
|
||||
time: { created: event.data.timestamp },
|
||||
time: { created: event.created },
|
||||
state: { status: "pending", input: "" },
|
||||
})
|
||||
})
|
||||
break
|
||||
case "session.next.tool.input.delta":
|
||||
case "tool.input.delta":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestTool(
|
||||
message.assistant(draft, index, event.data.assistantMessageID),
|
||||
|
|
@ -416,7 +411,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
if (match?.state.status === "pending") match.state.input += event.data.delta
|
||||
})
|
||||
break
|
||||
case "session.next.tool.input.ended":
|
||||
case "tool.input.ended":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestTool(
|
||||
message.assistant(draft, index, event.data.assistantMessageID),
|
||||
|
|
@ -425,19 +420,19 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
if (match?.state.status === "pending") match.state.input = event.data.text
|
||||
})
|
||||
break
|
||||
case "session.next.tool.called":
|
||||
case "tool.called":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestTool(
|
||||
message.assistant(draft, index, event.data.assistantMessageID),
|
||||
event.data.callID,
|
||||
)
|
||||
if (!match) return
|
||||
match.time.ran = event.data.timestamp
|
||||
match.time.ran = event.created
|
||||
match.provider = event.data.provider
|
||||
match.state = { status: "running", input: event.data.input, structured: {}, content: [] }
|
||||
})
|
||||
break
|
||||
case "session.next.tool.progress":
|
||||
case "tool.progress":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestTool(
|
||||
message.assistant(draft, index, event.data.assistantMessageID),
|
||||
|
|
@ -448,7 +443,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
match.state.content = [...event.data.content]
|
||||
})
|
||||
break
|
||||
case "session.next.tool.success":
|
||||
case "tool.success":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestTool(
|
||||
message.assistant(draft, index, event.data.assistantMessageID),
|
||||
|
|
@ -467,10 +462,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
metadata: match.provider?.metadata,
|
||||
resultMetadata: event.data.provider.metadata,
|
||||
}
|
||||
match.time.completed = event.data.timestamp
|
||||
match.time.completed = event.created
|
||||
})
|
||||
break
|
||||
case "session.next.tool.failed":
|
||||
case "tool.failed":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestTool(
|
||||
message.assistant(draft, index, event.data.assistantMessageID),
|
||||
|
|
@ -490,21 +485,21 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
metadata: match.provider?.metadata,
|
||||
resultMetadata: event.data.provider.metadata,
|
||||
}
|
||||
match.time.completed = event.data.timestamp
|
||||
match.time.completed = event.created
|
||||
})
|
||||
break
|
||||
case "session.next.reasoning.started":
|
||||
case "reasoning.started":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.assistant(draft, index, event.data.assistantMessageID)?.content.push({
|
||||
type: "reasoning",
|
||||
id: event.data.reasoningID,
|
||||
text: "",
|
||||
providerMetadata: event.data.providerMetadata,
|
||||
time: { created: event.data.timestamp },
|
||||
time: { created: event.created },
|
||||
})
|
||||
})
|
||||
break
|
||||
case "session.next.reasoning.delta":
|
||||
case "reasoning.delta":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestReasoning(
|
||||
message.assistant(draft, index, event.data.assistantMessageID),
|
||||
|
|
@ -513,7 +508,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
if (match) match.text += event.data.delta
|
||||
})
|
||||
break
|
||||
case "session.next.reasoning.ended":
|
||||
case "reasoning.ended":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestReasoning(
|
||||
message.assistant(draft, index, event.data.assistantMessageID),
|
||||
|
|
@ -521,38 +516,38 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
)
|
||||
if (match) {
|
||||
match.text = event.data.text
|
||||
match.time = { created: match.time?.created ?? event.data.timestamp, completed: event.data.timestamp }
|
||||
match.time = { created: match.time?.created ?? event.created, completed: event.created }
|
||||
if (event.data.providerMetadata !== undefined) match.providerMetadata = event.data.providerMetadata
|
||||
}
|
||||
})
|
||||
break
|
||||
case "session.next.retried":
|
||||
case "session.next.compaction.started":
|
||||
case "retried":
|
||||
case "compaction.started":
|
||||
setStore("session", "status", event.data.sessionID, "running")
|
||||
break
|
||||
case "session.next.execution.settled":
|
||||
case "execution.settled":
|
||||
setStore("session", "status", event.data.sessionID, "idle")
|
||||
break
|
||||
case "session.next.revert.staged":
|
||||
case "revert.staged":
|
||||
if (store.session.info[event.data.sessionID])
|
||||
setStore("session", "info", event.data.sessionID, "revert", event.data.revert)
|
||||
break
|
||||
case "session.next.revert.cleared":
|
||||
case "session.next.revert.committed":
|
||||
case "revert.cleared":
|
||||
case "revert.committed":
|
||||
if (store.session.info[event.data.sessionID])
|
||||
setStore("session", "info", event.data.sessionID, "revert", undefined)
|
||||
break
|
||||
case "session.next.compaction.delta":
|
||||
case "compaction.delta":
|
||||
break
|
||||
case "session.next.compaction.ended":
|
||||
case "compaction.ended":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.append(draft, index, {
|
||||
id: event.data.messageID,
|
||||
id: messageIDFromEvent(event.id),
|
||||
type: "compaction",
|
||||
reason: event.data.reason,
|
||||
summary: event.data.text,
|
||||
recent: event.data.recent,
|
||||
time: { created: event.data.timestamp },
|
||||
time: { created: event.created },
|
||||
})
|
||||
})
|
||||
break
|
||||
|
|
@ -705,8 +700,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
return liveByID.get(message.id) ?? message
|
||||
}),
|
||||
...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)
|
||||
messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index])))
|
||||
setStore("session", "message", sessionID, messages)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
|||
client: OpencodeClient
|
||||
api: OpenCodeClient
|
||||
discover?: () => Promise<{ client: OpencodeClient; api: OpenCodeClient }>
|
||||
// Stops and starts the managed service; present only in service mode.
|
||||
reload?: () => Promise<void>
|
||||
}) => {
|
||||
const abort = new AbortController()
|
||||
let client = props.client
|
||||
|
|
@ -138,6 +140,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
|||
return connection.connectedOnce
|
||||
},
|
||||
},
|
||||
reload: props.reload,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -74,20 +74,17 @@ const tui: TuiPlugin = async (api) => {
|
|||
notify(api, sessionID, "Session done", session?.parentID ? "subagent_done" : "done")
|
||||
}
|
||||
|
||||
api.event.on("session.next.prompted", (event) => started(event.data.sessionID))
|
||||
api.event.on("session.next.shell.started", (event) => started(event.data.sessionID))
|
||||
api.event.on("session.next.step.started", (event) => started(event.data.sessionID))
|
||||
api.event.on("session.next.retried", (event) => started(event.data.sessionID))
|
||||
api.event.on("session.next.compaction.started", (event) => started(event.data.sessionID))
|
||||
api.event.on("session.next.execution.settled", (event) => {
|
||||
if (event.data.outcome === "failure" && active.has(event.data.sessionID) && !errored.has(event.data.sessionID)) {
|
||||
errored.add(event.data.sessionID)
|
||||
notify(api, event.data.sessionID, "Session error", "error")
|
||||
}
|
||||
if (event.data.outcome === "interrupted") errored.add(event.data.sessionID)
|
||||
api.event.on("prompt.promoted", (event) => started(event.data.sessionID))
|
||||
api.event.on("shell.started", (event) => started(event.data.sessionID))
|
||||
api.event.on("step.started", (event) => started(event.data.sessionID))
|
||||
api.event.on("retried", (event) => started(event.data.sessionID))
|
||||
api.event.on("compaction.started", (event) => started(event.data.sessionID))
|
||||
api.event.on("shell.ended", (event) => ended(event.data.sessionID))
|
||||
api.event.on("step.ended", (event) => {
|
||||
if (event.data.finish === "tool-calls") return
|
||||
ended(event.data.sessionID)
|
||||
})
|
||||
api.event.on("session.next.step.failed", (event) => {
|
||||
api.event.on("step.failed", (event) => {
|
||||
const sessionID = event.data.sessionID
|
||||
if (!active.has(sessionID)) return
|
||||
errored.add(sessionID)
|
||||
|
|
|
|||
|
|
@ -1081,9 +1081,7 @@ function SessionMessageView(props: { message: SessionMessage }) {
|
|||
<UserMessage message={props.message as SessionMessageUser} />
|
||||
</Match>
|
||||
<Match when={props.message.type === "shell"}>
|
||||
<box paddingLeft={3}>
|
||||
<text>{props.message.type === "shell" ? `$ ${props.message.command}\n${props.message.output}` : ""}</text>
|
||||
</box>
|
||||
<ShellMessage message={props.message as Extract<SessionMessage, { type: "shell" }>} />
|
||||
</Match>
|
||||
<Match when={props.message.type === "agent-switched" || props.message.type === "model-switched"}>
|
||||
<SessionSwitchMessageV2 message={props.message} />
|
||||
|
|
@ -1359,6 +1357,29 @@ function RevertMessage(props: {
|
|||
)
|
||||
}
|
||||
|
||||
function ShellMessage(props: { message: Extract<SessionMessage, { type: "shell" }> }) {
|
||||
const { theme } = useTheme()
|
||||
const output = createMemo(() => stripAnsi(props.message.output.trim()))
|
||||
|
||||
return (
|
||||
<box
|
||||
border={["left"]}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
gap={1}
|
||||
backgroundColor={theme.backgroundPanel}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
borderColor={theme.background}
|
||||
>
|
||||
<text fg={theme.text}>$ {props.message.command}</text>
|
||||
<Show when={output()}>
|
||||
<text fg={theme.textMuted}>{output()}</text>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function UserMessage(props: { message: SessionMessageUser }) {
|
||||
const ctx = use()
|
||||
const data = useData()
|
||||
|
|
|
|||
|
|
@ -126,45 +126,49 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
|||
return index === -1 ? rows.length : index
|
||||
}
|
||||
|
||||
const message = (event: { data: { sessionID: string; messageID: string } }) => {
|
||||
if (event.data.sessionID === sessionID()) appendMessage(event.data.messageID)
|
||||
const message = (event: { id: string; data: { sessionID: string } }) => {
|
||||
if (event.data.sessionID === sessionID()) appendMessage(event.id.replace(/^evt_/, "msg_"))
|
||||
}
|
||||
const input = (event: { data: { sessionID: string; inputID: string } }) => {
|
||||
if (event.data.sessionID === sessionID()) appendMessage(event.data.inputID)
|
||||
}
|
||||
const subscriptions = [
|
||||
data.on("session.next.prompt.admitted", message),
|
||||
data.on("session.next.prompted", message),
|
||||
data.on("session.next.context.updated", message),
|
||||
data.on("session.next.synthetic", (event) => {
|
||||
if (event.data.sessionID === sessionID() && event.data.description?.trim()) appendMessage(event.data.messageID)
|
||||
data.on("prompt.admitted", input),
|
||||
data.on("prompt.promoted", input),
|
||||
data.on("session.context.updated", message),
|
||||
data.on("synthetic", (event) => {
|
||||
if (event.data.sessionID === sessionID() && event.data.description?.trim())
|
||||
appendMessage(event.id.replace(/^evt_/, "msg_"))
|
||||
}),
|
||||
data.on("session.next.shell.started", message),
|
||||
data.on("session.next.agent.switched", message),
|
||||
data.on("session.next.model.switched", message),
|
||||
data.on("session.next.compaction.ended", message),
|
||||
data.on("session.next.text.delta", (event) => {
|
||||
data.on("shell.started", message),
|
||||
data.on("agent.selected", message),
|
||||
data.on("model.selected", message),
|
||||
data.on("compaction.ended", message),
|
||||
data.on("text.delta", (event) => {
|
||||
if (event.data.sessionID === sessionID())
|
||||
appendPart({ messageID: event.data.assistantMessageID, partID: event.data.textID })
|
||||
}),
|
||||
data.on("session.next.text.ended", (event) => {
|
||||
data.on("text.ended", (event) => {
|
||||
if (event.data.sessionID === sessionID() && event.data.text.trim())
|
||||
appendPart({ messageID: event.data.assistantMessageID, partID: event.data.textID })
|
||||
}),
|
||||
data.on("session.next.reasoning.delta", (event) => {
|
||||
data.on("reasoning.delta", (event) => {
|
||||
if (event.data.sessionID === sessionID())
|
||||
appendPart({ messageID: event.data.assistantMessageID, partID: event.data.reasoningID })
|
||||
}),
|
||||
data.on("session.next.reasoning.ended", (event) => {
|
||||
data.on("reasoning.ended", (event) => {
|
||||
if (event.data.sessionID === sessionID() && event.data.text.trim())
|
||||
appendPart({ messageID: event.data.assistantMessageID, partID: event.data.reasoningID })
|
||||
}),
|
||||
data.on("session.next.tool.input.started", (event) => {
|
||||
data.on("tool.input.started", (event) => {
|
||||
if (event.data.sessionID === sessionID())
|
||||
appendPart({ messageID: event.data.assistantMessageID, partID: event.data.callID }, event.data.name)
|
||||
}),
|
||||
data.on("session.next.step.ended", (event) => {
|
||||
data.on("step.ended", (event) => {
|
||||
if (event.data.sessionID !== sessionID() || ["tool-calls", "unknown"].includes(event.data.finish)) return
|
||||
appendFooter(event.data.assistantMessageID)
|
||||
}),
|
||||
data.on("session.next.step.failed", (event) => {
|
||||
data.on("step.failed", (event) => {
|
||||
if (event.data.sessionID === sessionID()) appendFooter(event.data.assistantMessageID)
|
||||
}),
|
||||
]
|
||||
|
|
@ -229,8 +233,6 @@ function hasPart(rows: SessionRow[], ref: PartRef) {
|
|||
return rows.some((row) => {
|
||||
if (row.type === "part") return row.ref.messageID === ref.messageID && row.ref.partID === ref.partID
|
||||
if (row.type !== "group") return false
|
||||
return [...row.refs, ...row.pending].some(
|
||||
(item) => item.messageID === ref.messageID && item.partID === ref.partID,
|
||||
)
|
||||
return [...row.refs, ...row.pending].some((item) => item.messageID === ref.messageID && item.partID === ref.partID)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,177 +0,0 @@
|
|||
import type { CliRenderer, Renderable } from "@opentui/core"
|
||||
import { createMockKeys, createMockMouse, type MockInput, type MockMouse } from "@opentui/core/testing"
|
||||
import { SimulationRenderer } from "./renderer"
|
||||
import { SimulationTrace } from "./trace"
|
||||
|
||||
export interface KeyModifiers {
|
||||
readonly ctrl?: boolean
|
||||
readonly shift?: boolean
|
||||
readonly meta?: boolean
|
||||
readonly super?: boolean
|
||||
readonly hyper?: boolean
|
||||
}
|
||||
|
||||
export type Action =
|
||||
| { readonly type: "typeText"; readonly text: string }
|
||||
| { readonly type: "pressKey"; readonly key: string; readonly modifiers?: KeyModifiers }
|
||||
| { readonly type: "pressEnter" }
|
||||
| { readonly type: "pressArrow"; readonly direction: "up" | "down" | "left" | "right" }
|
||||
| { readonly type: "focus"; readonly target: number }
|
||||
| { readonly type: "click"; readonly target: number; readonly x: number; readonly y: number }
|
||||
|
||||
export interface Element {
|
||||
readonly id: string
|
||||
readonly num: number
|
||||
readonly x: number
|
||||
readonly y: number
|
||||
readonly width: number
|
||||
readonly height: number
|
||||
readonly focusable: boolean
|
||||
readonly focused: boolean
|
||||
readonly clickable: boolean
|
||||
readonly editor: boolean
|
||||
}
|
||||
|
||||
export interface Harness {
|
||||
readonly renderer: CliRenderer
|
||||
readonly mockInput: MockInput
|
||||
readonly mockMouse: MockMouse
|
||||
readonly renderOnce: () => Promise<void>
|
||||
readonly screen: () => string
|
||||
}
|
||||
|
||||
type RenderBuffer = {
|
||||
readonly width: number
|
||||
readonly height: number
|
||||
getRealCharBytes(includeAnsi?: boolean): Uint8Array
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder()
|
||||
|
||||
function children(renderable: Renderable) {
|
||||
return renderable.getChildren().filter((child): child is Renderable => "num" in child)
|
||||
}
|
||||
|
||||
function all(renderable: Renderable): Renderable[] {
|
||||
return [renderable, ...children(renderable).flatMap(all)]
|
||||
}
|
||||
|
||||
function mouseListeners(renderable: Renderable) {
|
||||
const general = Reflect.get(renderable, "_mouseListener")
|
||||
const specific = Reflect.get(renderable, "_mouseListeners")
|
||||
return Boolean(general) || (specific && typeof specific === "object" && Object.keys(specific).length > 0)
|
||||
}
|
||||
|
||||
function hit(renderer: CliRenderer, renderable: Renderable) {
|
||||
if (renderable.width <= 0 || renderable.height <= 0) return false
|
||||
const x = Math.floor(renderable.screenX + renderable.width / 2)
|
||||
const y = Math.floor(renderable.screenY + renderable.height / 2)
|
||||
return renderer.hitTest(x, y) === renderable.num
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the harness the simulation server drives.
|
||||
*
|
||||
* When the renderer is the fake simulation renderer, its TestRendererSetup
|
||||
* provides the supported testing APIs. For the visible terminal renderer the
|
||||
* harness falls back to `requestRender` + `idle` and reading the private
|
||||
* `currentRenderBuffer`.
|
||||
*/
|
||||
export function createHarness(renderer: CliRenderer): Harness {
|
||||
const setup = SimulationRenderer.setupFor(renderer)
|
||||
return {
|
||||
renderer,
|
||||
mockInput: setup?.mockInput ?? createMockKeys(renderer),
|
||||
mockMouse: setup?.mockMouse ?? createMockMouse(renderer),
|
||||
renderOnce:
|
||||
setup?.renderOnce ??
|
||||
(async () => {
|
||||
renderer.requestRender()
|
||||
await renderer.idle()
|
||||
}),
|
||||
screen:
|
||||
setup?.captureCharFrame ??
|
||||
(() => decoder.decode((Reflect.get(renderer, "currentRenderBuffer") as RenderBuffer).getRealCharBytes(true))),
|
||||
}
|
||||
}
|
||||
|
||||
export function elements(renderer: CliRenderer): Element[] {
|
||||
return all(renderer.root)
|
||||
.filter((renderable) => renderable.visible && !renderable.isDestroyed)
|
||||
.map((renderable) => {
|
||||
const clickable = mouseListeners(renderable) && hit(renderer, renderable)
|
||||
return {
|
||||
id: renderable.id,
|
||||
num: renderable.num,
|
||||
x: renderable.screenX,
|
||||
y: renderable.screenY,
|
||||
width: renderable.width,
|
||||
height: renderable.height,
|
||||
focusable: renderable.focusable,
|
||||
focused: renderable.focused,
|
||||
clickable,
|
||||
editor: renderer.currentFocusedEditor === renderable,
|
||||
} satisfies Element
|
||||
})
|
||||
.filter((element) => element.focusable || element.clickable || element.editor)
|
||||
}
|
||||
|
||||
export function actions(renderer: CliRenderer, options: { text?: string } = {}): Action[] {
|
||||
const items = elements(renderer)
|
||||
return [
|
||||
...(renderer.currentFocusedEditor
|
||||
? ([{ type: "typeText", text: options.text ?? "hello" }, { type: "pressEnter" }] satisfies Action[])
|
||||
: []),
|
||||
...items.filter((item) => item.focusable && !item.focused).map((item) => ({ type: "focus" as const, target: item.num })),
|
||||
...items
|
||||
.filter((item) => item.clickable)
|
||||
.map((item) => ({
|
||||
type: "click" as const,
|
||||
target: item.num,
|
||||
x: Math.floor(item.x + item.width / 2),
|
||||
y: Math.floor(item.y + item.height / 2),
|
||||
})),
|
||||
{ type: "pressArrow", direction: "down" },
|
||||
{ type: "pressArrow", direction: "up" },
|
||||
]
|
||||
}
|
||||
|
||||
export function state(harness: Harness) {
|
||||
return {
|
||||
screen: harness.screen(),
|
||||
focused: {
|
||||
renderable: harness.renderer.currentFocusedRenderable?.num,
|
||||
editor: Boolean(harness.renderer.currentFocusedEditor),
|
||||
},
|
||||
elements: elements(harness.renderer),
|
||||
actions: actions(harness.renderer),
|
||||
}
|
||||
}
|
||||
|
||||
export async function execute(harness: Harness, action: Action) {
|
||||
SimulationTrace.add("ui.action", { action })
|
||||
switch (action.type) {
|
||||
case "typeText":
|
||||
await harness.mockInput.typeText(action.text)
|
||||
break
|
||||
case "pressKey":
|
||||
harness.mockInput.pressKey(action.key, action.modifiers)
|
||||
break
|
||||
case "pressEnter":
|
||||
harness.mockInput.pressEnter()
|
||||
break
|
||||
case "pressArrow":
|
||||
harness.mockInput.pressArrow(action.direction)
|
||||
break
|
||||
case "focus":
|
||||
all(harness.renderer.root).find((item) => item.num === action.target)?.focus()
|
||||
break
|
||||
case "click":
|
||||
await harness.mockMouse.click(action.x, action.y)
|
||||
break
|
||||
}
|
||||
await harness.renderOnce()
|
||||
return state(harness)
|
||||
}
|
||||
|
||||
export * as SimulationActions from "./actions"
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
import type { CliRenderer, CliRendererConfig } from "@opentui/core"
|
||||
import { createTestRenderer, type TestRendererSetup } from "@opentui/core/testing"
|
||||
|
||||
const setups = new WeakMap<CliRenderer, TestRendererSetup>()
|
||||
|
||||
/**
|
||||
* Creates the fake simulation renderer: a real CliRenderer backed by an
|
||||
* in-memory screen buffer instead of a terminal. The TestRendererSetup is
|
||||
* kept module-side (keyed by renderer) so the harness can use the supported
|
||||
* testing APIs without app code carrying it around.
|
||||
*/
|
||||
export async function create(options: CliRendererConfig): Promise<CliRenderer> {
|
||||
const setup = await createTestRenderer({
|
||||
...options,
|
||||
width: Number(process.env.OPENCODE_SIMULATION_TUI_WIDTH) || 100,
|
||||
height: Number(process.env.OPENCODE_SIMULATION_TUI_HEIGHT) || 40,
|
||||
})
|
||||
setups.set(setup.renderer, setup)
|
||||
return setup.renderer
|
||||
}
|
||||
|
||||
export function setupFor(renderer: CliRenderer): TestRendererSetup | undefined {
|
||||
return setups.get(renderer)
|
||||
}
|
||||
|
||||
export * as SimulationRenderer from "./renderer"
|
||||
|
|
@ -1,174 +0,0 @@
|
|||
import { SimulationActions, type Action, type Harness } from "./actions"
|
||||
import { SimulationTrace } from "./trace"
|
||||
|
||||
const DefaultPort = 40900
|
||||
const MaxPortAttempts = 100
|
||||
|
||||
type JsonRpcRequest = {
|
||||
readonly jsonrpc: "2.0"
|
||||
readonly id?: string | number | null
|
||||
readonly method: string
|
||||
readonly params?: unknown
|
||||
}
|
||||
|
||||
type JsonRpcResponse = {
|
||||
readonly jsonrpc: "2.0"
|
||||
readonly id: string | number | null
|
||||
readonly result?: unknown
|
||||
readonly error?: {
|
||||
readonly code: number
|
||||
readonly message: string
|
||||
readonly data?: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export interface Server {
|
||||
readonly url: string
|
||||
readonly stop: () => void
|
||||
}
|
||||
|
||||
function isEnabled() {
|
||||
return process.env.OPENCODE_SIMULATION === "1" || process.env.OPENCODE_SIMULATION === "true"
|
||||
}
|
||||
|
||||
function isPortUnavailable(error: unknown) {
|
||||
const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase()
|
||||
return message.includes("eaddrinuse") || message.includes("address already in use") || message.includes(" in use")
|
||||
}
|
||||
|
||||
function parseRequest(input: string | Buffer): JsonRpcRequest {
|
||||
const value = JSON.parse(typeof input === "string" ? input : input.toString()) as unknown
|
||||
if (typeof value !== "object" || value === null) throw new Error("Invalid JSON-RPC request")
|
||||
if (!("jsonrpc" in value) || value.jsonrpc !== "2.0") throw new Error("Invalid JSON-RPC version")
|
||||
if (!("method" in value) || typeof value.method !== "string") throw new Error("Invalid JSON-RPC method")
|
||||
return value as JsonRpcRequest
|
||||
}
|
||||
|
||||
function isAction(input: unknown): input is Action {
|
||||
if (typeof input !== "object" || input === null || !("type" in input)) return false
|
||||
switch (input.type) {
|
||||
case "typeText":
|
||||
return "text" in input && typeof input.text === "string"
|
||||
case "pressKey":
|
||||
return "key" in input && typeof input.key === "string"
|
||||
case "pressEnter":
|
||||
return true
|
||||
case "pressArrow":
|
||||
return "direction" in input && ["up", "down", "left", "right"].includes(String(input.direction))
|
||||
case "focus":
|
||||
return "target" in input && typeof input.target === "number"
|
||||
case "click":
|
||||
return (
|
||||
"target" in input &&
|
||||
typeof input.target === "number" &&
|
||||
"x" in input &&
|
||||
typeof input.x === "number" &&
|
||||
"y" in input &&
|
||||
typeof input.y === "number"
|
||||
)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function actionParam(params: unknown) {
|
||||
if (typeof params !== "object" || params === null || !("action" in params)) throw new Error("Missing action")
|
||||
if (!isAction(params.action)) throw new Error("Invalid action")
|
||||
return params.action
|
||||
}
|
||||
|
||||
function response(id: JsonRpcRequest["id"], result: unknown): JsonRpcResponse | undefined {
|
||||
if (id === undefined) return undefined
|
||||
return { jsonrpc: "2.0", id, result }
|
||||
}
|
||||
|
||||
function errorResponse(id: JsonRpcRequest["id"], error: unknown): JsonRpcResponse {
|
||||
return {
|
||||
jsonrpc: "2.0",
|
||||
id: id ?? null,
|
||||
error: {
|
||||
code: -32000,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function handle(harness: Harness, request: JsonRpcRequest) {
|
||||
switch (request.method) {
|
||||
case "ui.state": {
|
||||
const result = SimulationActions.state(harness)
|
||||
SimulationTrace.add("ui.state", { elements: result.elements.length, actions: result.actions.length })
|
||||
return result
|
||||
}
|
||||
case "ui.action":
|
||||
return SimulationActions.execute(harness, actionParam(request.params))
|
||||
case "ui.render": {
|
||||
await harness.renderOnce()
|
||||
const result = SimulationActions.state(harness)
|
||||
SimulationTrace.add("ui.render", { elements: result.elements.length, actions: result.actions.length })
|
||||
return result
|
||||
}
|
||||
case "trace.list":
|
||||
return { records: SimulationTrace.list() }
|
||||
case "trace.clear":
|
||||
SimulationTrace.clear()
|
||||
return { cleared: true }
|
||||
case "trace.export":
|
||||
return SimulationTrace.exportTrace()
|
||||
}
|
||||
throw new Error(`Unknown simulation method: ${request.method}`)
|
||||
}
|
||||
|
||||
function serve(
|
||||
harness: Harness,
|
||||
port = DefaultPort,
|
||||
attempts = MaxPortAttempts,
|
||||
): Bun.Server<{ readonly simulation: true }> {
|
||||
try {
|
||||
return Bun.serve<{ readonly simulation: true }>({
|
||||
hostname: "127.0.0.1",
|
||||
port,
|
||||
fetch(request, server) {
|
||||
if (server.upgrade(request, { data: { simulation: true } })) return undefined
|
||||
return new Response("opencode simulation websocket", { status: 426 })
|
||||
},
|
||||
websocket: {
|
||||
open() {
|
||||
SimulationTrace.add("control.connect")
|
||||
},
|
||||
close() {
|
||||
SimulationTrace.add("control.disconnect")
|
||||
},
|
||||
async message(socket, message) {
|
||||
let request: JsonRpcRequest | undefined
|
||||
try {
|
||||
request = parseRequest(message)
|
||||
const result = await handle(harness, request)
|
||||
const next = response(request.id, result)
|
||||
if (next) socket.send(JSON.stringify(next))
|
||||
} catch (error) {
|
||||
socket.send(JSON.stringify(errorResponse(request?.id, error)))
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (!isPortUnavailable(error) || attempts <= 1 || port >= 65535) throw error
|
||||
return serve(harness, port + 1, attempts - 1)
|
||||
}
|
||||
}
|
||||
|
||||
export function start(harness: Harness): Server | undefined {
|
||||
if (!isEnabled()) return
|
||||
const server = serve(harness)
|
||||
const url = `ws://${server.hostname}:${server.port}`
|
||||
SimulationTrace.add("control.start", { url })
|
||||
return {
|
||||
url,
|
||||
stop: () => {
|
||||
SimulationTrace.add("control.stop", { url })
|
||||
server.stop(true)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export * as SimulationServer from "./server"
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
import { createCliRenderer, type CliRenderer, type CliRendererConfig } from "@opentui/core"
|
||||
import { SimulationActions } from "./actions"
|
||||
import { SimulationRenderer } from "./renderer"
|
||||
import { SimulationServer } from "./server"
|
||||
|
||||
/**
|
||||
* Simulation-mode renderer entry point.
|
||||
*
|
||||
* Creates the renderer (fake when OPENCODE_SIMULATION_RENDERER=fake, the
|
||||
* normal visible renderer otherwise) and starts the simulation control
|
||||
* server against it. The server stops when the renderer is destroyed, so the
|
||||
* caller only manages the renderer lifecycle.
|
||||
*/
|
||||
export async function createSimulation(options: CliRendererConfig): Promise<CliRenderer> {
|
||||
const renderer =
|
||||
process.env.OPENCODE_SIMULATION_RENDERER === "fake"
|
||||
? await SimulationRenderer.create(options)
|
||||
: await createCliRenderer(options)
|
||||
const server = SimulationServer.start(SimulationActions.createHarness(renderer))
|
||||
if (server) {
|
||||
process.stderr.write(`opencode simulation websocket: ${server.url}\n`)
|
||||
renderer.once("destroy", () => server.stop())
|
||||
}
|
||||
return renderer
|
||||
}
|
||||
|
||||
export * as Simulation from "./simulation"
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
export type TraceRecord = {
|
||||
readonly id: number
|
||||
readonly time: string
|
||||
readonly type: string
|
||||
readonly data?: unknown
|
||||
}
|
||||
|
||||
const records: TraceRecord[] = []
|
||||
let nextID = 0
|
||||
|
||||
export function add(type: string, data?: unknown) {
|
||||
const record = {
|
||||
id: ++nextID,
|
||||
time: new Date().toISOString(),
|
||||
type,
|
||||
...(data === undefined ? {} : { data }),
|
||||
} satisfies TraceRecord
|
||||
records.push(record)
|
||||
return record
|
||||
}
|
||||
|
||||
export function list() {
|
||||
return [...records]
|
||||
}
|
||||
|
||||
export function clear() {
|
||||
records.length = 0
|
||||
nextID = 0
|
||||
}
|
||||
|
||||
export function exportTrace() {
|
||||
return {
|
||||
records: list(),
|
||||
}
|
||||
}
|
||||
|
||||
export * as SimulationTrace from "./trace"
|
||||
|
|
@ -33,10 +33,7 @@ async function setup() {
|
|||
},
|
||||
},
|
||||
event: {
|
||||
on: <Type extends V2Event["type"]>(
|
||||
type: Type,
|
||||
handler: (event: Extract<V2Event, { type: Type }>) => void,
|
||||
) => {
|
||||
on: <Type extends V2Event["type"]>(type: Type, handler: (event: Extract<V2Event, { type: Type }>) => void) => {
|
||||
const list = handlers.get(type) ?? []
|
||||
const wrapped = handler as (event: V2Event) => void
|
||||
list.push(wrapped)
|
||||
|
|
@ -86,28 +83,37 @@ function permission(id: string, sessionID = "session"): PermissionV2Request {
|
|||
}
|
||||
}
|
||||
|
||||
function durable(sessionID: string) {
|
||||
return { aggregateID: sessionID, seq: 0, version: 1 }
|
||||
}
|
||||
|
||||
function stepStarted(id: string, sessionID = "session"): V2Event {
|
||||
return {
|
||||
id,
|
||||
type: "session.next.step.started",
|
||||
created: 0,
|
||||
type: "step.started",
|
||||
durable: durable(sessionID),
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID: `msg_${id}`,
|
||||
timestamp: 0,
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function executionSettled(id: string, sessionID = "session"): V2Event {
|
||||
function executionSettled(id: string, sessionID = "session", finish = "stop"): V2Event {
|
||||
return {
|
||||
id,
|
||||
type: "session.next.execution.settled",
|
||||
created: 0,
|
||||
type: "step.ended",
|
||||
durable: durable(sessionID),
|
||||
data: {
|
||||
sessionID,
|
||||
timestamp: 0,
|
||||
outcome: "success",
|
||||
assistantMessageID: `msg_${id}`,
|
||||
finish,
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -115,11 +121,12 @@ function executionSettled(id: string, sessionID = "session"): V2Event {
|
|||
function stepFailed(id: string, sessionID = "session"): V2Event {
|
||||
return {
|
||||
id,
|
||||
type: "session.next.step.failed",
|
||||
created: 0,
|
||||
type: "step.failed",
|
||||
durable: durable(sessionID),
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID: `msg_${id}`,
|
||||
timestamp: 0,
|
||||
error: { type: "unknown", message: "boom" },
|
||||
},
|
||||
}
|
||||
|
|
@ -143,8 +150,8 @@ describe("internal notifications TUI plugin", () => {
|
|||
test("notifies for form and permission requests with blurred notifications and always-on sounds", async () => {
|
||||
const harness = await setup()
|
||||
|
||||
harness.emit({ id: "event-1", type: "form.created", data: { form: form("form-1") } })
|
||||
harness.emit({ id: "event-2", type: "permission.v2.asked", data: permission("permission-1") })
|
||||
harness.emit({ id: "event-1", created: 0, type: "form.created", data: { form: form("form-1") } })
|
||||
harness.emit({ id: "event-2", created: 0, type: "permission.v2.asked", data: permission("permission-1") })
|
||||
|
||||
expect(harness.notifications).toEqual([formNotification, permissionNotification])
|
||||
})
|
||||
|
|
@ -152,23 +159,25 @@ describe("internal notifications TUI plugin", () => {
|
|||
test("dedupes pending forms and permissions until they are resolved", async () => {
|
||||
const harness = await setup()
|
||||
|
||||
harness.emit({ id: "event-1", type: "form.created", data: { form: form("form-1") } })
|
||||
harness.emit({ id: "event-2", type: "form.created", data: { form: form("form-1") } })
|
||||
harness.emit({ id: "event-1", created: 0, type: "form.created", data: { form: form("form-1") } })
|
||||
harness.emit({ id: "event-2", created: 0, type: "form.created", data: { form: form("form-1") } })
|
||||
harness.emit({
|
||||
id: "event-3",
|
||||
created: 0,
|
||||
type: "form.replied",
|
||||
data: { sessionID: "session", id: "form-1", answer: {} },
|
||||
})
|
||||
harness.emit({ id: "event-4", type: "form.created", data: { form: form("form-1") } })
|
||||
harness.emit({ id: "event-4", created: 0, type: "form.created", data: { form: form("form-1") } })
|
||||
|
||||
harness.emit({ id: "event-5", type: "permission.v2.asked", data: permission("permission-1") })
|
||||
harness.emit({ id: "event-6", type: "permission.v2.asked", data: permission("permission-1") })
|
||||
harness.emit({ id: "event-5", created: 0, type: "permission.v2.asked", data: permission("permission-1") })
|
||||
harness.emit({ id: "event-6", created: 0, type: "permission.v2.asked", data: permission("permission-1") })
|
||||
harness.emit({
|
||||
id: "event-7",
|
||||
created: 0,
|
||||
type: "permission.v2.replied",
|
||||
data: { sessionID: "session", requestID: "permission-1", reply: "once" },
|
||||
})
|
||||
harness.emit({ id: "event-8", type: "permission.v2.asked", data: permission("permission-1") })
|
||||
harness.emit({ id: "event-8", created: 0, type: "permission.v2.asked", data: permission("permission-1") })
|
||||
|
||||
expect(harness.notifications).toEqual([
|
||||
formNotification,
|
||||
|
|
@ -198,7 +207,7 @@ describe("internal notifications TUI plugin", () => {
|
|||
test("uses sound-only notifications and subagent_done sound for subagent sessions", async () => {
|
||||
const harness = await setup()
|
||||
|
||||
harness.emit({ id: "event-1", type: "form.created", data: { form: form("form-1", "subagent") } })
|
||||
harness.emit({ id: "event-1", created: 0, type: "form.created", data: { form: form("form-1", "subagent") } })
|
||||
harness.emit(stepStarted("event-2", "subagent"))
|
||||
harness.emit(executionSettled("event-3", "subagent"))
|
||||
|
||||
|
|
@ -241,12 +250,14 @@ describe("internal notifications TUI plugin", () => {
|
|||
harness.emit(stepStarted("event-1", "abort"))
|
||||
harness.emit({
|
||||
id: "event-2",
|
||||
created: 0,
|
||||
type: "session.error",
|
||||
data: { sessionID: "abort", error: { name: "MessageAbortedError", data: { message: "Aborted" } } },
|
||||
})
|
||||
harness.emit(stepStarted("event-3", "timeout"))
|
||||
harness.emit({
|
||||
id: "event-4",
|
||||
created: 0,
|
||||
type: "session.error",
|
||||
data: { sessionID: "timeout", error: { name: "UnknownError", data: { message: "SSE read timed out" } } },
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import type { V2Event } from "@opencode-ai/sdk/v2"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { onMount } from "solid-js"
|
||||
import { ProjectProvider } from "../../../src/context/project"
|
||||
import { SDKProvider } from "../../../src/context/sdk"
|
||||
|
|
@ -21,6 +23,10 @@ function emitEvent(events: ReturnType<typeof createEventStream>, event: V2Event)
|
|||
events.emit({ ...event, location: { directory } })
|
||||
}
|
||||
|
||||
function durable(sessionID: string, seq = 0, version = 1) {
|
||||
return { aggregateID: sessionID, seq, version }
|
||||
}
|
||||
|
||||
test("refreshes resources into reactive getters", async () => {
|
||||
const events = createEventStream()
|
||||
const location = {
|
||||
|
|
@ -43,8 +49,8 @@ test("refreshes resources into reactive getters", async () => {
|
|||
if (url.pathname === "/api/session/ses_test/message")
|
||||
return json({
|
||||
data: [
|
||||
{ id: "msg_second", type: "user", text: "Second", time: { created: 2 } },
|
||||
{ id: "msg_first", type: "user", text: "First", time: { created: 1 } },
|
||||
{ id: "msg_second", created: 0, type: "user", text: "Second", time: { created: 2 } },
|
||||
{ id: "msg_first", created: 0, type: "user", text: "First", time: { created: 1 } },
|
||||
],
|
||||
cursor: {},
|
||||
})
|
||||
|
|
@ -183,7 +189,9 @@ test("connectedOnce is false until first connect and persists across disconnect"
|
|||
)
|
||||
const connect = () =>
|
||||
stream?.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ id: "evt_connected", type: "server.connected", data: {} })}\n\n`),
|
||||
encoder.encode(
|
||||
`data: ${JSON.stringify({ id: "evt_connected", created: 0, type: "server.connected", data: {} })}\n\n`,
|
||||
),
|
||||
)
|
||||
const disconnect = () => {
|
||||
stream?.close()
|
||||
|
|
@ -260,11 +268,12 @@ test("tracks session status from active sessions and execution events", async ()
|
|||
|
||||
emitEvent(events, {
|
||||
id: "evt_step_started",
|
||||
type: "session.next.step.started",
|
||||
created: 0,
|
||||
type: "step.started",
|
||||
durable: durable("session-live"),
|
||||
data: {
|
||||
sessionID: "session-live",
|
||||
assistantMessageID: "message-live",
|
||||
timestamp: 1,
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
},
|
||||
|
|
@ -273,11 +282,12 @@ test("tracks session status from active sessions and execution events", async ()
|
|||
|
||||
emitEvent(events, {
|
||||
id: "evt_step_ended",
|
||||
type: "session.next.step.ended",
|
||||
created: 0,
|
||||
type: "step.ended",
|
||||
durable: durable("session-live", 1, 2),
|
||||
data: {
|
||||
sessionID: "session-live",
|
||||
assistantMessageID: "message-live",
|
||||
timestamp: 2,
|
||||
finish: "stop",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
|
|
@ -291,10 +301,10 @@ test("tracks session status from active sessions and execution events", async ()
|
|||
|
||||
emitEvent(events, {
|
||||
id: "evt_execution_settled",
|
||||
type: "session.next.execution.settled",
|
||||
created: 0,
|
||||
type: "execution.settled",
|
||||
data: {
|
||||
sessionID: "session-live",
|
||||
timestamp: 3,
|
||||
outcome: "success",
|
||||
},
|
||||
})
|
||||
|
|
@ -302,11 +312,12 @@ test("tracks session status from active sessions and execution events", async ()
|
|||
|
||||
emitEvent(events, {
|
||||
id: "evt_failed_step_started",
|
||||
type: "session.next.step.started",
|
||||
created: 0,
|
||||
type: "step.started",
|
||||
durable: durable("session-failed"),
|
||||
data: {
|
||||
sessionID: "session-failed",
|
||||
assistantMessageID: "message-failed",
|
||||
timestamp: 3,
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
},
|
||||
|
|
@ -315,11 +326,12 @@ test("tracks session status from active sessions and execution events", async ()
|
|||
|
||||
emitEvent(events, {
|
||||
id: "evt_step_failed",
|
||||
type: "session.next.step.failed",
|
||||
created: 0,
|
||||
type: "step.failed",
|
||||
durable: durable("session-failed", 1, 2),
|
||||
data: {
|
||||
sessionID: "session-failed",
|
||||
assistantMessageID: "message-failed",
|
||||
timestamp: 4,
|
||||
error: { type: "unknown", message: "Provider unavailable" },
|
||||
},
|
||||
})
|
||||
|
|
@ -331,10 +343,10 @@ test("tracks session status from active sessions and execution events", async ()
|
|||
|
||||
emitEvent(events, {
|
||||
id: "evt_failed_execution_settled",
|
||||
type: "session.next.execution.settled",
|
||||
created: 0,
|
||||
type: "execution.settled",
|
||||
data: {
|
||||
sessionID: "session-failed",
|
||||
timestamp: 5,
|
||||
outcome: "failure",
|
||||
error: { type: "unknown", message: "Provider unavailable" },
|
||||
},
|
||||
|
|
@ -403,7 +415,7 @@ test("refreshes integrations after integration updates", async () => {
|
|||
expect(data.location.integration.list()).toEqual([])
|
||||
const before = { ...requests }
|
||||
|
||||
emitEvent(events, { id: "evt_integration", type: "integration.updated", data: {} })
|
||||
emitEvent(events, { id: "evt_integration", created: 0, type: "integration.updated", data: {} })
|
||||
await wait(() => data.location.integration.list()?.length === 1)
|
||||
await wait(() => requests.model > before.model && requests.provider > before.provider)
|
||||
expect(data.location.integration.list()?.[0]).toMatchObject({ id: "openai", name: "OpenAI" })
|
||||
|
|
@ -441,7 +453,7 @@ test("refreshes effective catalog data after catalog updates", async () => {
|
|||
try {
|
||||
await wait(() => requests.model > 0 && requests.provider > 0)
|
||||
const before = { ...requests }
|
||||
emitEvent(events, { id: "evt_catalog", type: "catalog.updated", data: {} })
|
||||
emitEvent(events, { id: "evt_catalog", created: 0, type: "catalog.updated", data: {} })
|
||||
await wait(() => requests.model > before.model && requests.provider > before.provider)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
|
|
@ -488,7 +500,7 @@ test("refreshes agents after agent updates", async () => {
|
|||
|
||||
try {
|
||||
await wait(() => data.location.agent.list()?.[0]?.id === "build")
|
||||
emitEvent(events, { id: "evt_agent", type: "agent.updated", data: {} })
|
||||
emitEvent(events, { id: "evt_agent", created: 0, type: "agent.updated", data: {} })
|
||||
await wait(() => data.location.agent.list()?.[0]?.id === "reviewer")
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
|
|
@ -533,7 +545,7 @@ test("refreshes references after updates", async () => {
|
|||
try {
|
||||
await mounted
|
||||
await wait(() => requests === 1)
|
||||
emitEvent(events, { id: "evt_reference_1", type: "reference.updated", data: {} })
|
||||
emitEvent(events, { id: "evt_reference_1", created: 0, type: "reference.updated", data: {} })
|
||||
await wait(() => data.location.reference.list()?.length === 1)
|
||||
expect(data.location.reference.list()?.[0]?.name).toBe("docs")
|
||||
} finally {
|
||||
|
|
@ -548,7 +560,10 @@ test("keeps shell state scoped to location", async () => {
|
|||
if (url.pathname !== "/api/shell") return
|
||||
const requestDirectory = url.searchParams.get("location[directory]")
|
||||
return json({
|
||||
location: { directory: requestDirectory ?? directory, project: { id: "proj_test", directory: requestDirectory ?? directory } },
|
||||
location: {
|
||||
directory: requestDirectory ?? directory,
|
||||
project: { id: "proj_test", directory: requestDirectory ?? directory },
|
||||
},
|
||||
data: [
|
||||
{
|
||||
id: requestDirectory === other ? "sh_other" : "sh_default",
|
||||
|
|
@ -591,6 +606,7 @@ test("keeps shell state scoped to location", async () => {
|
|||
|
||||
events.emit({
|
||||
id: "evt_shell_created",
|
||||
created: 0,
|
||||
type: "shell.created",
|
||||
location: { directory: other },
|
||||
data: {
|
||||
|
|
@ -639,6 +655,7 @@ test("adds and dismisses permission requests from live events", async () => {
|
|||
await wait(() => data.connection.status() === "connected")
|
||||
emitEvent(events, {
|
||||
id: "evt_permission_asked_1",
|
||||
created: 0,
|
||||
type: "permission.v2.asked",
|
||||
data: {
|
||||
id: "per_1",
|
||||
|
|
@ -649,6 +666,7 @@ test("adds and dismisses permission requests from live events", async () => {
|
|||
})
|
||||
emitEvent(events, {
|
||||
id: "evt_permission_asked_2",
|
||||
created: 0,
|
||||
type: "permission.v2.asked",
|
||||
data: {
|
||||
id: "per_2",
|
||||
|
|
@ -661,6 +679,7 @@ test("adds and dismisses permission requests from live events", async () => {
|
|||
|
||||
emitEvent(events, {
|
||||
id: "evt_permission_replied_1",
|
||||
created: 0,
|
||||
type: "permission.v2.replied",
|
||||
data: { sessionID: "ses_1", requestID: "per_1", reply: "once" },
|
||||
})
|
||||
|
|
@ -669,6 +688,7 @@ test("adds and dismisses permission requests from live events", async () => {
|
|||
|
||||
emitEvent(events, {
|
||||
id: "evt_permission_replied_2",
|
||||
created: 0,
|
||||
type: "permission.v2.replied",
|
||||
data: { sessionID: "ses_1", requestID: "per_2", reply: "reject" },
|
||||
})
|
||||
|
|
@ -709,47 +729,52 @@ test("settles pending tools when a live failure arrives", async () => {
|
|||
await mounted
|
||||
emitEvent(events, {
|
||||
id: "evt_agent_1",
|
||||
type: "session.next.agent.switched",
|
||||
data: { sessionID: "session-1", messageID: "msg_agent_1", timestamp: 0, agent: "build" },
|
||||
created: 0,
|
||||
type: "agent.selected",
|
||||
durable: durable("session-1"),
|
||||
data: { sessionID: "session-1", agent: "build" },
|
||||
})
|
||||
emitEvent(events, {
|
||||
id: "evt_model_1",
|
||||
type: "session.next.model.switched",
|
||||
created: 0,
|
||||
type: "model.selected",
|
||||
durable: durable("session-1", 1),
|
||||
data: {
|
||||
sessionID: "session-1",
|
||||
messageID: "msg_model_1",
|
||||
timestamp: 0,
|
||||
model: { id: "model-1", providerID: "provider-1" },
|
||||
},
|
||||
})
|
||||
emitEvent(events, {
|
||||
id: "evt_step_started_1",
|
||||
type: "session.next.step.started",
|
||||
created: 0,
|
||||
type: "step.started",
|
||||
durable: durable("session-1", 2),
|
||||
data: {
|
||||
sessionID: "session-1",
|
||||
assistantMessageID: "msg_explicit_assistant_9",
|
||||
timestamp: 1,
|
||||
agent: "build",
|
||||
model: { id: "model-1", providerID: "provider-1" },
|
||||
},
|
||||
})
|
||||
emitEvent(events, {
|
||||
id: "evt_input_1",
|
||||
type: "session.next.tool.input.started",
|
||||
created: 0,
|
||||
type: "tool.input.started",
|
||||
durable: durable("session-1", 3),
|
||||
data: {
|
||||
sessionID: "session-1",
|
||||
assistantMessageID: "msg_explicit_assistant_9",
|
||||
timestamp: 2,
|
||||
callID: "call-1",
|
||||
name: "bash",
|
||||
},
|
||||
})
|
||||
emitEvent(events, {
|
||||
id: "evt_called_1",
|
||||
type: "session.next.tool.called",
|
||||
created: 0,
|
||||
type: "tool.called",
|
||||
durable: durable("session-1", 4),
|
||||
data: {
|
||||
sessionID: "session-1",
|
||||
timestamp: 2,
|
||||
assistantMessageID: "msg_explicit_assistant_9",
|
||||
callID: "call-1",
|
||||
tool: "bash",
|
||||
|
|
@ -759,10 +784,11 @@ test("settles pending tools when a live failure arrives", async () => {
|
|||
})
|
||||
emitEvent(events, {
|
||||
id: "evt_failed_1",
|
||||
type: "session.next.tool.failed",
|
||||
created: 0,
|
||||
type: "tool.failed",
|
||||
durable: durable("session-1", 5),
|
||||
data: {
|
||||
sessionID: "session-1",
|
||||
timestamp: 3,
|
||||
assistantMessageID: "msg_explicit_assistant_9",
|
||||
callID: "call-1",
|
||||
error: { type: "unknown", message: "aborted" },
|
||||
|
|
@ -848,11 +874,12 @@ test("renders admitted prompts immediately with queued marker and clears when pr
|
|||
const unsubscribe = sync.listen((event) => received.push(event.name))
|
||||
emitEvent(events, {
|
||||
id: "evt_admitted_1",
|
||||
type: "session.next.prompt.admitted",
|
||||
created: 0,
|
||||
type: "prompt.admitted",
|
||||
durable: durable(sessionID),
|
||||
data: {
|
||||
sessionID,
|
||||
messageID,
|
||||
timestamp: 0,
|
||||
inputID: messageID,
|
||||
prompt: { text: "hello" },
|
||||
delivery: "steer",
|
||||
},
|
||||
|
|
@ -866,18 +893,17 @@ test("renders admitted prompts immediately with queued marker and clears when pr
|
|||
|
||||
emitEvent(events, {
|
||||
id: "evt_prompted_1",
|
||||
type: "session.next.prompted",
|
||||
created: 0,
|
||||
type: "prompt.promoted",
|
||||
durable: durable(sessionID, 1),
|
||||
data: {
|
||||
sessionID,
|
||||
messageID,
|
||||
timestamp: 0,
|
||||
prompt: { text: "hello" },
|
||||
delivery: "steer",
|
||||
inputID: messageID,
|
||||
},
|
||||
})
|
||||
|
||||
await wait(() => received.at(-1) === "session.next.prompted")
|
||||
expect(received.slice(-2)).toEqual(["session.next.prompt.admitted", "session.next.prompted"])
|
||||
await wait(() => received.at(-1) === "prompt.promoted")
|
||||
expect(received.slice(-2)).toEqual(["prompt.admitted", "prompt.promoted"])
|
||||
unsubscribe()
|
||||
const message = sync.session.message.list(sessionID)?.[0]
|
||||
expect(message?.type).toBe("user")
|
||||
|
|
@ -925,20 +951,21 @@ test("projects live context updates with their message ID", async () => {
|
|||
await mounted
|
||||
emitEvent(events, {
|
||||
id: "evt_context_1",
|
||||
type: "session.next.context.updated",
|
||||
created: 0,
|
||||
type: "session.context.updated",
|
||||
durable: durable("session-1"),
|
||||
data: {
|
||||
sessionID: "session-1",
|
||||
messageID: "msg_context_1",
|
||||
timestamp: 1,
|
||||
text: "Updated context",
|
||||
},
|
||||
})
|
||||
|
||||
await wait(() => sync.session.message.list("session-1")?.length === 1)
|
||||
expect(sync.session.message.list("session-1")?.[0]).toMatchObject({
|
||||
id: "msg_context_1",
|
||||
id: SessionMessage.ID.fromEvent(EventV2.ID.make("evt_context_1")),
|
||||
type: "system",
|
||||
text: "Updated context",
|
||||
time: { created: 0 },
|
||||
})
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ function event(payload: V2Event, input: { directory: string; project?: string; w
|
|||
function vcs(branch: string): V2Event {
|
||||
return {
|
||||
id: `evt_vcs_${branch}`,
|
||||
created: 0,
|
||||
type: "vcs.branch.updated",
|
||||
data: {
|
||||
branch,
|
||||
|
|
@ -40,6 +41,7 @@ function vcs(branch: string): V2Event {
|
|||
function update(version: string): V2Event {
|
||||
return {
|
||||
id: `evt_update_${version}`,
|
||||
created: 0,
|
||||
type: "installation.update-available",
|
||||
data: {
|
||||
version,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue