chore(tui): merge v2 into form branch
This commit is contained in:
commit
be0cd93c8f
235 changed files with 5364 additions and 3885 deletions
|
|
@ -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