mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-23 22:23:18 -04:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0672a8c146 | |||
| ec23ea1564 | |||
| e91951785c | |||
| 214b12bbe3 | |||
| c0718059b7 | |||
| faf1029723 |
@@ -13,6 +13,7 @@ import { EventManifest } from "@opencode-ai/schema/event-manifest"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { expect, type Page } from "@playwright/test"
|
||||
import { Schema } from "effect"
|
||||
import { wire, type Wire } from "@/test-fixture"
|
||||
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||
import { installSseTransport } from "../../utils/sse-transport"
|
||||
import { expectSessionReady } from "../../utils/waits"
|
||||
@@ -254,7 +255,7 @@ function describeEvent(event: OpenCodeEvent) {
|
||||
|
||||
export function event(
|
||||
type: "session.status",
|
||||
data: Extract<OpenCodeEvent, { type: "session.status" }>["data"],
|
||||
data: Wire<Extract<OpenCodeEvent, { type: "session.status" }>["data"]>,
|
||||
): OpenCodeEvent {
|
||||
return makeEvent(type, data)
|
||||
}
|
||||
@@ -465,7 +466,7 @@ export function userMessage(
|
||||
): SessionMessageUser {
|
||||
const id = input.id ?? userID
|
||||
const seeds = parts ?? [userText("Build the timeline stability matrix.", { id: `prt_${id}_text` })]
|
||||
return {
|
||||
return wire<SessionMessageUser>({
|
||||
id,
|
||||
type: "user",
|
||||
time: { created: input.created ?? 1700000000000 },
|
||||
@@ -499,7 +500,7 @@ export function userMessage(
|
||||
]
|
||||
}),
|
||||
...(input.summary === undefined ? {} : { metadata: { summary: input.summary as JsonValue } }),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function assistantMessage(
|
||||
@@ -519,7 +520,7 @@ export function assistantMessage(
|
||||
const ordinals = { text: 0, reasoning: 0 }
|
||||
const content = parts.map((part) => messageContent(part, id, ordinals))
|
||||
nextOrdinals.set(id, ordinals)
|
||||
return {
|
||||
return wire<SessionMessageAssistant>({
|
||||
id,
|
||||
type: "assistant",
|
||||
metadata: { parentID: input.parentID ?? userID },
|
||||
@@ -531,7 +532,7 @@ export function assistantMessage(
|
||||
tokens,
|
||||
...(input.completed === false ? {} : { finish: "stop" as const }),
|
||||
...(input.error ? { error: input.error } : {}),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function userText(text: string, input: Partial<Omit<TextSeed, "type" | "text">> = {}): TextSeed {
|
||||
@@ -643,8 +644,8 @@ export function project() {
|
||||
}
|
||||
}
|
||||
|
||||
export function session(input: Partial<Session> = {}): Session {
|
||||
return {
|
||||
export function session(input: Partial<Wire<Session>> = {}): Session {
|
||||
return wire<Session>({
|
||||
id: sessionID,
|
||||
projectID,
|
||||
location: { directory },
|
||||
@@ -653,7 +654,7 @@ export function session(input: Partial<Session> = {}): Session {
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
...input,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function messageContent(
|
||||
@@ -829,7 +830,7 @@ function partRef(id: string, messageID: string, type: PartRef["type"]): PartRef
|
||||
|
||||
function makeEvent<Type extends OpenCodeEvent["type"]>(
|
||||
type: Type,
|
||||
data: Extract<OpenCodeEvent, { type: Type }>["data"],
|
||||
data: Wire<Extract<OpenCodeEvent, { type: Type }>["data"]>,
|
||||
): OpenCodeEvent {
|
||||
const id = `evt_timeline_${String(++eventSequence).padStart(4, "0")}`
|
||||
const base = { id, created: 1700000002000 + eventSequence, type, data, location: { directory } }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { SessionMessageAssistant, SessionMessageInfo, SessionMessageUser } from "@opencode-ai/client/promise"
|
||||
import type { SessionMessageAssistant, SessionMessageUser } from "@opencode-ai/client/promise"
|
||||
import type { Page } from "@playwright/test"
|
||||
import { wire } from "@/test-fixture"
|
||||
import { expectSessionTitle } from "../../utils/waits"
|
||||
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||
import { benchmark, expect, withBenchmarkPage } from "../benchmark"
|
||||
@@ -12,16 +13,19 @@ type ParentHydrationBenchmarkMode = "natural" | "candidate"
|
||||
const mode = process.env.SESSION_PARENT_HYDRATION_BENCHMARK_MODE ?? "natural"
|
||||
if (mode !== "natural" && mode !== "candidate") throw new Error(`Unknown parent hydration benchmark mode: ${mode}`)
|
||||
const userID = "msg_parent_hydration_user"
|
||||
const userSeed = fixture.messages[fixture.targetID][0] as SessionMessageUser
|
||||
const user = {
|
||||
const userSeed = fixture.messages[fixture.targetID][0]
|
||||
if (userSeed?.type !== "user") throw new Error("Expected the first target fixture message to be a user message")
|
||||
const user = wire<SessionMessageUser>({
|
||||
...userSeed,
|
||||
id: userID,
|
||||
time: { created: 1700001000000 },
|
||||
} satisfies SessionMessageInfo
|
||||
const assistantSeed = fixture.messages[fixture.targetID][3] as SessionMessageAssistant
|
||||
})
|
||||
const assistantSeed = fixture.messages[fixture.targetID][3]
|
||||
if (assistantSeed?.type !== "assistant")
|
||||
throw new Error("Expected the fourth target fixture message to be an assistant message")
|
||||
const assistants = Array.from({ length: 14 }, (_, index) => {
|
||||
const messageID = `msg_parent_hydration_${String(index).padStart(2, "0")}`
|
||||
return {
|
||||
return wire<SessionMessageAssistant>({
|
||||
...assistantSeed,
|
||||
id: messageID,
|
||||
time: { created: 1700001001000 + index * 1_000, completed: 1700001001500 + index * 1_000 },
|
||||
@@ -30,7 +34,7 @@ const assistants = Array.from({ length: 14 }, (_, index) => {
|
||||
? { ...part, id: `call_parent_hydration_${String(index).padStart(2, "0")}_${partIndex}` }
|
||||
: part,
|
||||
),
|
||||
} satisfies SessionMessageInfo
|
||||
})
|
||||
})
|
||||
const messages = [user, ...assistants]
|
||||
const target = fixture.sessions.find((session) => session.id === fixture.targetID)!
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import type { JsonValue, OpenCodeEvent, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import type {
|
||||
JsonValue,
|
||||
OpenCodeEvent,
|
||||
SessionMessageAssistant,
|
||||
SessionMessageInfo,
|
||||
SessionMessageUser,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import type { Page } from "@playwright/test"
|
||||
import { wire, type Wire } from "@/test-fixture"
|
||||
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||
import { expectAppVisible, expectSessionTitle } from "../../utils/waits"
|
||||
import { expect } from "../benchmark"
|
||||
@@ -17,13 +24,16 @@ const title = "Timeline collapse state regression"
|
||||
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
|
||||
|
||||
type EventPayload = OpenCodeEvent
|
||||
type TextStartedEvent = Extract<OpenCodeEvent, { type: "session.text.started" }>
|
||||
type TextDeltaEvent = Extract<OpenCodeEvent, { type: "session.text.delta" }>
|
||||
type TimelineEventSeed = Pick<Wire<TextStartedEvent>, "type" | "data"> | Pick<Wire<TextDeltaEvent>, "type" | "data">
|
||||
|
||||
const userMessage = {
|
||||
const userMessage = wire<SessionMessageUser>({
|
||||
id: userMessageID,
|
||||
type: "user",
|
||||
time: { created: 1700000000000 },
|
||||
text: "Please edit the file.",
|
||||
} satisfies SessionMessageInfo
|
||||
})
|
||||
|
||||
const editPart: ToolSeed = {
|
||||
id: editPartID,
|
||||
@@ -39,20 +49,14 @@ const editPart: ToolSeed = {
|
||||
content: [{ type: "text", text: "Edited src/regression.ts" }],
|
||||
metadata: {
|
||||
files: [
|
||||
currentFile(
|
||||
"src/regression.ts",
|
||||
"export const value = 'before'\n",
|
||||
"export const value = 'after'\n",
|
||||
1,
|
||||
1,
|
||||
),
|
||||
currentFile("src/regression.ts", "export const value = 'before'\n", "export const value = 'after'\n", 1, 1),
|
||||
],
|
||||
},
|
||||
},
|
||||
time: { created: 1700000001000, ran: 1700000001000, completed: 1700000002000 },
|
||||
}
|
||||
|
||||
const assistantMessage = {
|
||||
const assistantMessage = wire<SessionMessageAssistant>({
|
||||
id: assistantMessageID,
|
||||
type: "assistant",
|
||||
time: { created: 1700000001000 },
|
||||
@@ -61,7 +65,7 @@ const assistantMessage = {
|
||||
cost: 0.01,
|
||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
content: [toolContent(editPart)],
|
||||
} satisfies SessionMessageInfo
|
||||
})
|
||||
|
||||
export async function setupTimelineBenchmark(
|
||||
page: Page,
|
||||
@@ -155,23 +159,29 @@ export async function setupTimelineBenchmark(
|
||||
|
||||
export function buildInitialStreamEvent(deltaCount: number): EventPayload[] {
|
||||
return [
|
||||
timelineEvent("session.text.started", { sessionID, assistantMessageID, ordinal: 0 }, true),
|
||||
timelineEvent("session.text.delta", {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
ordinal: 0,
|
||||
delta: `Streaming${streamChunk(0, deltaCount + 1)}\n\n\`\`\`ts\nconst initial = true\n\`\`\``,
|
||||
timelineEvent({ type: "session.text.started", data: { sessionID, assistantMessageID, ordinal: 0 } }),
|
||||
timelineEvent({
|
||||
type: "session.text.delta",
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
ordinal: 0,
|
||||
delta: `Streaming${streamChunk(0, deltaCount + 1)}\n\n\`\`\`ts\nconst initial = true\n\`\`\``,
|
||||
},
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
export function buildStreamDeltaEvents(deltaCount: number): EventPayload[] {
|
||||
return Array.from({ length: deltaCount }, (_, index) =>
|
||||
timelineEvent("session.text.delta", {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
ordinal: 0,
|
||||
delta: streamChunk(index + 1, deltaCount + 1),
|
||||
timelineEvent({
|
||||
type: "session.text.delta",
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
ordinal: 0,
|
||||
delta: streamChunk(index + 1, deltaCount + 1),
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -182,7 +192,7 @@ function performanceTurn(index: number) {
|
||||
const assistantID = `msg_0000_${suffix}_b_assistant`
|
||||
const before = historicalSource(index, false)
|
||||
const after = historicalSource(index, true)
|
||||
const parts = [
|
||||
const parts: ContentSeed[] = [
|
||||
...(index % 5 === 0
|
||||
? [
|
||||
{
|
||||
@@ -192,7 +202,7 @@ function performanceTurn(index: number) {
|
||||
type: "reasoning",
|
||||
text: `Reviewing the existing implementation. ${"constraint analysis ".repeat(20)}`,
|
||||
time: { start: 1690000001000 + index * 2_000, end: 1690000001200 + index * 2_000 },
|
||||
},
|
||||
} satisfies ContentSeed,
|
||||
]
|
||||
: []),
|
||||
{
|
||||
@@ -201,7 +211,7 @@ function performanceTurn(index: number) {
|
||||
messageID: assistantID,
|
||||
type: "text",
|
||||
text: historicalMarkdown(index),
|
||||
},
|
||||
} satisfies ContentSeed,
|
||||
...(index % 8 === 0
|
||||
? [
|
||||
{
|
||||
@@ -221,7 +231,7 @@ function performanceTurn(index: number) {
|
||||
ran: 1690000001200 + index * 2_000,
|
||||
completed: 1690000001400 + index * 2_000,
|
||||
},
|
||||
},
|
||||
} satisfies ContentSeed,
|
||||
]
|
||||
: []),
|
||||
...(index % 12 === 0
|
||||
@@ -241,7 +251,7 @@ function performanceTurn(index: number) {
|
||||
ran: 1690000001400 + index * 2_000,
|
||||
completed: 1690000001500 + index * 2_000,
|
||||
},
|
||||
},
|
||||
} satisfies ContentSeed,
|
||||
]
|
||||
: []),
|
||||
...(index % 16 === 0
|
||||
@@ -267,11 +277,11 @@ function performanceTurn(index: number) {
|
||||
ran: 1690000001500 + index * 2_000,
|
||||
completed: 1690000001700 + index * 2_000,
|
||||
},
|
||||
},
|
||||
} satisfies ContentSeed,
|
||||
]
|
||||
: []),
|
||||
] as unknown as ContentSeed[]
|
||||
return [
|
||||
]
|
||||
return wire<SessionMessageInfo[]>([
|
||||
{
|
||||
id: userID,
|
||||
type: "user",
|
||||
@@ -298,7 +308,7 @@ function performanceTurn(index: number) {
|
||||
return toolContent(part)
|
||||
}),
|
||||
},
|
||||
] satisfies SessionMessageInfo[]
|
||||
])
|
||||
}
|
||||
|
||||
type ToolSeed = {
|
||||
@@ -338,20 +348,22 @@ function toolContent(part: ToolSeed): SessionMessageAssistant["content"][number]
|
||||
|
||||
let eventSequence = 0
|
||||
|
||||
function timelineEvent<Type extends "session.text.started" | "session.text.delta">(
|
||||
type: Type,
|
||||
data: Extract<OpenCodeEvent, { type: Type }>["data"],
|
||||
durable = false,
|
||||
): Extract<OpenCodeEvent, { type: Type }> {
|
||||
function timelineEvent(seed: TimelineEventSeed): TextStartedEvent | TextDeltaEvent {
|
||||
eventSequence++
|
||||
return {
|
||||
if (seed.type === "session.text.started")
|
||||
return wire<TextStartedEvent>({
|
||||
id: `evt_timeline_benchmark_${eventSequence}`,
|
||||
created: 1700000002000 + eventSequence,
|
||||
...seed,
|
||||
location: { directory },
|
||||
durable: { aggregateID: sessionID, seq: eventSequence, version: 1 },
|
||||
})
|
||||
return wire<TextDeltaEvent>({
|
||||
id: `evt_timeline_benchmark_${eventSequence}`,
|
||||
created: 1700000002000 + eventSequence,
|
||||
type,
|
||||
data,
|
||||
...seed,
|
||||
location: { directory },
|
||||
...(durable ? { durable: { aggregateID: sessionID, seq: eventSequence, version: 1 } } : {}),
|
||||
} as unknown as Extract<OpenCodeEvent, { type: Type }>
|
||||
})
|
||||
}
|
||||
|
||||
function historicalMarkdown(index: number) {
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { createTwoFilesPatch } from "diff"
|
||||
import { wire } from "@/test-fixture"
|
||||
import type {
|
||||
JsonValue,
|
||||
SessionMessageAssistant,
|
||||
SessionMessageInfo,
|
||||
SessionMessageUser,
|
||||
} from "@opencode-ai/client/promise"
|
||||
|
||||
const words = [
|
||||
"alpha",
|
||||
@@ -64,13 +71,13 @@ function id(prefix: string, value: number) {
|
||||
|
||||
function userMessage(_sessionID: string, index: number, textLength: number, diffs: unknown[] = []): SessionMessageInfo {
|
||||
const messageID = id("msg_user", index)
|
||||
return {
|
||||
return wire<SessionMessageUser>({
|
||||
id: messageID,
|
||||
type: "user",
|
||||
time: { created: 1700000000000 + index * 10_000 },
|
||||
text: lorem(index, textLength),
|
||||
metadata: diffs.length ? { diffs: diffs as JsonValue } : undefined,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function assistantMessage(
|
||||
@@ -80,7 +87,7 @@ function assistantMessage(
|
||||
parts: MessagePart[],
|
||||
): SessionMessageInfo {
|
||||
const messageID = id("msg_assistant", index)
|
||||
return {
|
||||
return wire<SessionMessageAssistant>({
|
||||
id: messageID,
|
||||
type: "assistant",
|
||||
time: { created: 1700000000000 + index * 10_000 + 1_000, completed: 1700000000000 + index * 10_000 + 8_000 },
|
||||
@@ -90,7 +97,7 @@ function assistantMessage(
|
||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
finish: "stop",
|
||||
content: parts.map(messageContent),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function messageContent(part: MessagePart): SessionMessageAssistant["content"][number] {
|
||||
@@ -151,10 +158,7 @@ function toolPart(
|
||||
metadataOverride ??
|
||||
(tool === "patch"
|
||||
? {
|
||||
files: [
|
||||
patchFile(index, "modified"),
|
||||
patchFile(index + 1, index % 2 === 0 ? "added" : "deleted"),
|
||||
],
|
||||
files: [patchFile(index, "modified"), patchFile(index + 1, index % 2 === 0 ? "added" : "deleted")],
|
||||
}
|
||||
: tool === "edit" || tool === "write"
|
||||
? { files: [fileDiff(String(input.path ?? `src/generated/file-${index}.ts`), index)] }
|
||||
@@ -232,14 +236,20 @@ function turn(index: number): SessionMessageInfo[] {
|
||||
: []),
|
||||
textPart(index, 2, 160 + (index % 6) * 90),
|
||||
...(index % 4 === 0
|
||||
? [toolPart(index, 3, "edit", { path: `src/generated/file-${index}.ts`, oldString: "before", newString: "after" }, 700)]
|
||||
? [
|
||||
toolPart(
|
||||
index,
|
||||
3,
|
||||
"edit",
|
||||
{ path: `src/generated/file-${index}.ts`, oldString: "before", newString: "after" },
|
||||
700,
|
||||
),
|
||||
]
|
||||
: []),
|
||||
...(index % 6 === 0
|
||||
? [toolPart(index, 7, "write", { path: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)]
|
||||
: []),
|
||||
...(index % 8 === 0
|
||||
? [toolPart(index, 8, "patch", { patchText: `Update generated patch ${index}` }, 620)]
|
||||
: []),
|
||||
...(index % 8 === 0 ? [toolPart(index, 8, "patch", { patchText: `Update generated patch ${index}` }, 620)] : []),
|
||||
...(index % 7 === 0
|
||||
? [toolPart(index, 4, "shell", { command: "bun typecheck", description: "Verify generated output" }, 620)]
|
||||
: []),
|
||||
@@ -389,4 +399,3 @@ export function pageMessages(sessionID: string, limit: number, before?: string)
|
||||
cursor: start > 0 ? messages[start].id : undefined,
|
||||
}
|
||||
}
|
||||
import type { JsonValue, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||
import { expect, test, type Page, type Route } from "@playwright/test"
|
||||
import { wire, type Wire } from "@/test-fixture"
|
||||
import { installSseTransport } from "../utils/sse-transport"
|
||||
import { currentSession } from "../utils/mock-server"
|
||||
|
||||
@@ -81,7 +83,7 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
|
||||
await expect(page.getByRole("heading", { name: sessionB.title, exact: true })).toBeVisible()
|
||||
await transport.waitForConnection()
|
||||
|
||||
await transport.send({
|
||||
const backgroundPermission: Wire<OpenCodeEvent> = {
|
||||
id: "evt_permission_background_a",
|
||||
created: 1700000001000,
|
||||
type: "permission.asked",
|
||||
@@ -94,7 +96,8 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
|
||||
metadata: {},
|
||||
save: [],
|
||||
},
|
||||
})
|
||||
}
|
||||
await transport.send(wire<OpenCodeEvent>(backgroundPermission))
|
||||
|
||||
await expect
|
||||
.poll(() => permissionResponses)
|
||||
@@ -108,7 +111,7 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
|
||||
},
|
||||
])
|
||||
|
||||
await transport.send({
|
||||
const childBackgroundPermission: Wire<OpenCodeEvent> = {
|
||||
id: "evt_permission_background_a_child",
|
||||
created: 1700000002000,
|
||||
type: "permission.asked",
|
||||
@@ -121,7 +124,8 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
|
||||
metadata: {},
|
||||
save: [],
|
||||
},
|
||||
})
|
||||
}
|
||||
await transport.send(wire<OpenCodeEvent>(childBackgroundPermission))
|
||||
|
||||
await expect
|
||||
.poll(() => permissionResponses)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import { wire } from "@/test-fixture"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible, expectSessionTitle } from "../utils/waits"
|
||||
|
||||
@@ -108,14 +110,14 @@ async function openReview(page: Page) {
|
||||
return []
|
||||
},
|
||||
pageMessages: () => ({
|
||||
items: [
|
||||
items: wire<SessionMessageInfo[]>([
|
||||
{
|
||||
id: "msg_review_image_flash_regression",
|
||||
type: "user",
|
||||
time: { created: 1700000000000 },
|
||||
text: "Review this change.",
|
||||
},
|
||||
],
|
||||
]),
|
||||
}),
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import { wire } from "@/test-fixture"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible, expectSessionTitle } from "../utils/waits"
|
||||
|
||||
@@ -121,14 +123,14 @@ async function openReview(page: Page) {
|
||||
},
|
||||
],
|
||||
pageMessages: () => ({
|
||||
items: [
|
||||
items: wire<SessionMessageInfo[]>([
|
||||
{
|
||||
id: "msg_review_line_comment_regression",
|
||||
type: "user",
|
||||
time: { created: 1700000000000 },
|
||||
text: "Review this change.",
|
||||
},
|
||||
],
|
||||
]),
|
||||
}),
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { wire } from "@/test-fixture"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
@@ -8,7 +9,7 @@ const directory = "C:/OpenCode/SessionMessageRevert"
|
||||
const projectID = "proj_session_message_revert"
|
||||
const sessionID = "ses_session_message_revert"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const messages = [
|
||||
const messages = wire<SessionMessageInfo[]>([
|
||||
{ id: "msg_first", type: "user", text: "First prompt", time: { created: 1 } },
|
||||
{
|
||||
id: "msg_first_reply",
|
||||
@@ -19,7 +20,7 @@ const messages = [
|
||||
time: { created: 2, completed: 3 },
|
||||
},
|
||||
{ id: "msg_second", type: "user", text: "Second prompt", time: { created: 4 } },
|
||||
] satisfies SessionMessageInfo[]
|
||||
])
|
||||
|
||||
test("reverts directly to the selected user message", async ({ page }) => {
|
||||
const staged: { sessionID: string; messageID: string }[] = []
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { wire, type Wire } from "@/test-fixture"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { installSseTransport } from "../utils/sse-transport"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
@@ -138,7 +140,7 @@ test("restores the draft caret before typing after a request dock closes", async
|
||||
}),
|
||||
)
|
||||
.toBe(cursor)
|
||||
await transport.send({
|
||||
const created: Wire<OpenCodeEvent> = {
|
||||
id: "evt_form_created",
|
||||
created: 1700000001000,
|
||||
type: "form.created",
|
||||
@@ -161,18 +163,20 @@ test("restores the draft caret before typing after a request dock closes", async
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
await transport.send(wire<OpenCodeEvent>(created))
|
||||
const question = page.locator('[data-component="dock-prompt"][data-kind="question"]')
|
||||
await expect(question).toBeVisible()
|
||||
await expect(editor).toHaveCount(0)
|
||||
|
||||
await transport.send({
|
||||
const cancelled: Wire<OpenCodeEvent> = {
|
||||
id: "evt_form_cancelled",
|
||||
created: 1700000002000,
|
||||
type: "form.cancelled",
|
||||
location: { directory },
|
||||
data: { sessionID, id: "frm_question_caret" },
|
||||
})
|
||||
}
|
||||
await transport.send(wire<OpenCodeEvent>(cancelled))
|
||||
await expect(question).toHaveCount(0)
|
||||
await expect(editor).toBeVisible()
|
||||
await page.keyboard.press("x")
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { expect, test, type Locator, type Page } from "@playwright/test"
|
||||
import type { JsonValue, OpenCodeEvent, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import type {
|
||||
JsonValue,
|
||||
OpenCodeEvent,
|
||||
SessionMessageAssistant,
|
||||
SessionMessageInfo,
|
||||
SessionMessageUser,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { wire, type Wire } from "@/test-fixture"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible, expectSessionTitle } from "../utils/waits"
|
||||
import { createTwoFilesPatch } from "diff"
|
||||
@@ -25,12 +32,12 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
const userMessage = {
|
||||
const userMessage = wire<SessionMessageUser>({
|
||||
id: userMessageID,
|
||||
type: "user",
|
||||
time: { created: 1700000000000 },
|
||||
text: "Please edit the file.",
|
||||
} satisfies SessionMessageInfo
|
||||
})
|
||||
|
||||
const editPart = {
|
||||
id: editPartID,
|
||||
@@ -72,7 +79,7 @@ const streamedTextPart = {
|
||||
text: "Streaming added a later assistant text part.",
|
||||
}
|
||||
|
||||
const assistantMessage = {
|
||||
const assistantMessage = wire<SessionMessageAssistant>({
|
||||
id: assistantMessageID,
|
||||
type: "assistant",
|
||||
time: { created: 1700000001000 },
|
||||
@@ -81,7 +88,7 @@ const assistantMessage = {
|
||||
cost: 0.01,
|
||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
content: [toolContent(editPart)],
|
||||
} satisfies SessionMessageInfo
|
||||
})
|
||||
|
||||
test.describe("regression: session timeline local row state", () => {
|
||||
test("keeps a manually collapsed tool collapsed when later assistant content streams", async ({ page }) => {
|
||||
@@ -330,56 +337,61 @@ let eventSequence = -1
|
||||
|
||||
function textEvents(): OpenCodeEvent[] {
|
||||
return [
|
||||
eventValue("session.text.started", { sessionID, assistantMessageID, ordinal: 0 }, 1),
|
||||
eventValue(
|
||||
"session.text.ended",
|
||||
{
|
||||
eventValue(1, (envelope) => ({
|
||||
...envelope,
|
||||
type: "session.text.started",
|
||||
data: { sessionID, assistantMessageID, ordinal: 0 },
|
||||
})),
|
||||
eventValue(1, (envelope) => ({
|
||||
...envelope,
|
||||
type: "session.text.ended",
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
ordinal: 0,
|
||||
text: streamedTextPart.text,
|
||||
},
|
||||
1,
|
||||
),
|
||||
})),
|
||||
]
|
||||
}
|
||||
|
||||
function toolEvents(part: typeof editPart): OpenCodeEvent[] {
|
||||
return [
|
||||
eventValue(
|
||||
"session.tool.input.started",
|
||||
{
|
||||
eventValue(1, (envelope) => ({
|
||||
...envelope,
|
||||
type: "session.tool.input.started",
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
id: part.callID,
|
||||
name: part.tool,
|
||||
},
|
||||
1,
|
||||
),
|
||||
eventValue(
|
||||
"session.tool.input.ended",
|
||||
{
|
||||
})),
|
||||
eventValue(1, (envelope) => ({
|
||||
...envelope,
|
||||
type: "session.tool.input.ended",
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
id: part.callID,
|
||||
text: JSON.stringify(part.state.input),
|
||||
},
|
||||
1,
|
||||
),
|
||||
eventValue(
|
||||
"session.tool.called",
|
||||
{
|
||||
})),
|
||||
eventValue(1, (envelope) => ({
|
||||
...envelope,
|
||||
type: "session.tool.called",
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
id: part.callID,
|
||||
input: part.state.input,
|
||||
executed: true,
|
||||
},
|
||||
1,
|
||||
),
|
||||
eventValue(
|
||||
"session.tool.success",
|
||||
{
|
||||
})),
|
||||
eventValue(2, (envelope) => ({
|
||||
...envelope,
|
||||
type: "session.tool.success",
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
id: part.callID,
|
||||
@@ -387,25 +399,30 @@ function toolEvents(part: typeof editPart): OpenCodeEvent[] {
|
||||
metadata: part.state.metadata as Record<string, JsonValue>,
|
||||
executed: true,
|
||||
},
|
||||
2,
|
||||
),
|
||||
})),
|
||||
]
|
||||
}
|
||||
|
||||
function eventValue<Type extends OpenCodeEvent["type"]>(
|
||||
type: Type,
|
||||
data: Extract<OpenCodeEvent, { type: Type }>["data"],
|
||||
version: 1 | 2,
|
||||
): Extract<OpenCodeEvent, { type: Type }> {
|
||||
type EventEnvelope<Version extends 1 | 2> = {
|
||||
id: string
|
||||
created: number
|
||||
location: { directory: string }
|
||||
durable: { aggregateID: string; seq: number; version: Version }
|
||||
}
|
||||
|
||||
function eventValue<Version extends 1 | 2>(
|
||||
version: Version,
|
||||
build: (envelope: EventEnvelope<Version>) => Wire<OpenCodeEvent>,
|
||||
): OpenCodeEvent {
|
||||
eventSequence++
|
||||
return {
|
||||
id: `evt_collapse_${eventSequence}`,
|
||||
created: 1700000002000 + eventSequence,
|
||||
type,
|
||||
data,
|
||||
location: { directory },
|
||||
durable: { aggregateID: sessionID, seq: eventSequence, version },
|
||||
} as unknown as Extract<OpenCodeEvent, { type: Type }>
|
||||
return wire<OpenCodeEvent>(
|
||||
build({
|
||||
id: `evt_collapse_${eventSequence}`,
|
||||
created: 1700000002000 + eventSequence,
|
||||
location: { directory },
|
||||
durable: { aggregateID: sessionID, seq: eventSequence, version },
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function readExpanded(element: Element) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import type { JsonValue, OpenCodeEvent, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import { wire, type Wire } from "@/test-fixture"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible, expectSessionTitle } from "../utils/waits"
|
||||
import {
|
||||
@@ -209,13 +210,7 @@ function turn(index: number, target: boolean, status: "running" | "completed" =
|
||||
const content: SessionMessageAssistant["content"] = target
|
||||
? [
|
||||
toolContent(
|
||||
contextTool(
|
||||
contextIDs[0]!,
|
||||
assistantID,
|
||||
"read",
|
||||
{ path: "src/recent-a.ts", offset: 0, limit: 120 },
|
||||
status,
|
||||
),
|
||||
contextTool(contextIDs[0]!, assistantID, "read", { path: "src/recent-a.ts", offset: 0, limit: 120 }, status),
|
||||
),
|
||||
toolContent(contextTool(contextIDs[1]!, assistantID, "glob", { path: directory, pattern: "**/*.ts" }, status)),
|
||||
toolContent(
|
||||
@@ -231,7 +226,7 @@ function turn(index: number, target: boolean, status: "running" | "completed" =
|
||||
{ type: "text", text: "This assistant text is immediately after the explored context group." },
|
||||
]
|
||||
: [{ type: "text", text: `Assistant filler ${index}. ${"filler ".repeat(60)}` }]
|
||||
return [
|
||||
return wire<SessionMessageInfo[]>([
|
||||
{
|
||||
id: userID,
|
||||
type: "user",
|
||||
@@ -249,7 +244,7 @@ function turn(index: number, target: boolean, status: "running" | "completed" =
|
||||
finish: "stop",
|
||||
content,
|
||||
},
|
||||
]
|
||||
])
|
||||
}
|
||||
|
||||
function contextTool(
|
||||
@@ -314,9 +309,10 @@ let eventSequence = -1
|
||||
|
||||
function toolEvents(part: ContextTool): OpenCodeEvent[] {
|
||||
return [
|
||||
eventValue(
|
||||
"session.tool.success",
|
||||
{
|
||||
eventValue(2, (envelope) => ({
|
||||
...envelope,
|
||||
type: "session.tool.success",
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID: part.messageID,
|
||||
id: part.callID,
|
||||
@@ -324,25 +320,30 @@ function toolEvents(part: ContextTool): OpenCodeEvent[] {
|
||||
metadata: part.state.metadata,
|
||||
executed: true,
|
||||
},
|
||||
2,
|
||||
),
|
||||
})),
|
||||
]
|
||||
}
|
||||
|
||||
function eventValue<Type extends OpenCodeEvent["type"]>(
|
||||
type: Type,
|
||||
data: Extract<OpenCodeEvent, { type: Type }>["data"],
|
||||
version: 1 | 2,
|
||||
): Extract<OpenCodeEvent, { type: Type }> {
|
||||
type EventEnvelope<Version extends 1 | 2> = {
|
||||
id: string
|
||||
created: number
|
||||
location: { directory: string }
|
||||
durable: { aggregateID: string; seq: number; version: Version }
|
||||
}
|
||||
|
||||
function eventValue<Version extends 1 | 2>(
|
||||
version: Version,
|
||||
build: (envelope: EventEnvelope<Version>) => Wire<OpenCodeEvent>,
|
||||
): OpenCodeEvent {
|
||||
eventSequence++
|
||||
return {
|
||||
id: `evt_context_resize_${eventSequence}`,
|
||||
created: 1700000002000 + eventSequence,
|
||||
type,
|
||||
data,
|
||||
location: { directory },
|
||||
durable: { aggregateID: sessionID, seq: eventSequence, version },
|
||||
} as unknown as Extract<OpenCodeEvent, { type: Type }>
|
||||
return wire<OpenCodeEvent>(
|
||||
build({
|
||||
id: `evt_context_resize_${eventSequence}`,
|
||||
created: 1700000002000 + eventSequence,
|
||||
location: { directory },
|
||||
durable: { aggregateID: sessionID, seq: eventSequence, version },
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async function mockServer(page: Page, events: OpenCodeEvent[] = [], fixtureMessages = messages) {
|
||||
|
||||
@@ -1,36 +1,39 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import type {
|
||||
OpenCodeEvent,
|
||||
SessionMessageAssistant,
|
||||
SessionMessageInfo,
|
||||
SessionMessageUser,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { wire } from "@/test-fixture"
|
||||
import { event, session, sessionID, setupTimeline } from "../performance/timeline-stability/fixture"
|
||||
|
||||
const user = { id: "msg_user", type: "user", text: "Run it", time: { created: 1 } } satisfies SessionMessageInfo
|
||||
const user = wire<SessionMessageUser>({ id: "msg_user", type: "user", text: "Run it", time: { created: 1 } })
|
||||
|
||||
const assistant = (
|
||||
completed: boolean,
|
||||
tool = false,
|
||||
childID?: string,
|
||||
background = false,
|
||||
): SessionMessageAssistant => ({
|
||||
id: "msg_assistant",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: tool
|
||||
? [
|
||||
{
|
||||
type: "tool",
|
||||
id: "call_subagent",
|
||||
name: "subagent",
|
||||
state: {
|
||||
status: "running",
|
||||
input: { description: "Inspect code", ...(background ? { background: true } : {}) },
|
||||
metadata: { status: "running", ...(childID ? { sessionID: childID } : {}) },
|
||||
const assistant = (completed: boolean, tool = false, childID?: string, background = false): SessionMessageAssistant =>
|
||||
wire<SessionMessageAssistant>({
|
||||
id: "msg_assistant",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: tool
|
||||
? [
|
||||
{
|
||||
type: "tool",
|
||||
id: "call_subagent",
|
||||
name: "subagent",
|
||||
state: {
|
||||
status: "running",
|
||||
input: { description: "Inspect code", ...(background ? { background: true } : {}) },
|
||||
metadata: { status: "running", ...(childID ? { sessionID: childID } : {}) },
|
||||
},
|
||||
time: { created: 2 },
|
||||
},
|
||||
time: { created: 2 },
|
||||
},
|
||||
]
|
||||
: [{ type: "text", text: "Working" }],
|
||||
time: { created: 2, ...(completed ? { completed: 3 } : {}) },
|
||||
})
|
||||
]
|
||||
: [{ type: "text", text: "Working" }],
|
||||
time: { created: 2, ...(completed ? { completed: 3 } : {}) },
|
||||
})
|
||||
|
||||
test("renders current protocol notices in CLI order", async ({ page }) => {
|
||||
const ownerWarnings: string[] = []
|
||||
@@ -39,7 +42,7 @@ test("renders current protocol notices in CLI order", async ({ page }) => {
|
||||
ownerWarnings.push(message.text())
|
||||
})
|
||||
await setupTimeline(page, {
|
||||
sessionMessages: [
|
||||
sessionMessages: wire<SessionMessageInfo[]>([
|
||||
user,
|
||||
{ id: "msg_agent", type: "agent-switched", agent: "explore", time: { created: 2 } },
|
||||
assistant(true),
|
||||
@@ -59,7 +62,7 @@ test("renders current protocol notices in CLI order", async ({ page }) => {
|
||||
time: { created: 5 },
|
||||
},
|
||||
{ id: "msg_skill", type: "skill", skill: "review", name: "Review", text: "instructions", time: { created: 6 } },
|
||||
],
|
||||
]),
|
||||
})
|
||||
|
||||
const notices = page.locator('[data-slot="session-timeline-notice"]')
|
||||
@@ -97,10 +100,10 @@ test("waits for completion before labeling requested background work", async ({
|
||||
})
|
||||
|
||||
test("navigates from a running subagent card and hides background controls in the child", async ({ page }) => {
|
||||
const childID = "ses_running_child"
|
||||
const childID = Session.ID.make("ses_running_child")
|
||||
await setupTimeline(page, {
|
||||
sessionMessages: [user, assistant(false, true, childID)],
|
||||
sessions: [session(), session({ id: childID, parentID: sessionID, title: "Sleep for 5 minutes" })],
|
||||
sessions: [session(), session({ id: childID, parentID: Session.ID.make(sessionID), title: "Sleep for 5 minutes" })],
|
||||
sessionStatus: { [sessionID]: { type: "busy" }, [childID]: { type: "busy" } },
|
||||
})
|
||||
|
||||
@@ -111,10 +114,10 @@ test("navigates from a running subagent card and hides background controls in th
|
||||
})
|
||||
|
||||
test("shows a badge for active background work", async ({ page }) => {
|
||||
const childID = "ses_background_child"
|
||||
const childID = Session.ID.make("ses_background_child")
|
||||
await setupTimeline(page, {
|
||||
sessionMessages: [user, assistant(true)],
|
||||
sessions: [session(), session({ id: childID, parentID: sessionID })],
|
||||
sessions: [session(), session({ id: childID, parentID: Session.ID.make(sessionID) })],
|
||||
sessionStatus: { [childID]: { type: "busy" } },
|
||||
})
|
||||
|
||||
@@ -122,10 +125,10 @@ test("shows a badge for active background work", async ({ page }) => {
|
||||
})
|
||||
|
||||
test("separates blocking and already-backgrounded work into two rows", async ({ page }) => {
|
||||
const backgroundID = "ses_background_existing"
|
||||
const blockingID = "ses_background_blocking"
|
||||
const backgroundID = Session.ID.make("ses_background_existing")
|
||||
const blockingID = Session.ID.make("ses_background_blocking")
|
||||
const timeline = await setupTimeline(page, {
|
||||
sessionMessages: [
|
||||
sessionMessages: wire<SessionMessageInfo[]>([
|
||||
user,
|
||||
{
|
||||
id: "msg_backgrounded",
|
||||
@@ -180,11 +183,11 @@ test("separates blocking and already-backgrounded work into two rows", async ({
|
||||
],
|
||||
time: { created: 4 },
|
||||
},
|
||||
],
|
||||
]),
|
||||
sessions: [
|
||||
session(),
|
||||
session({ id: backgroundID, parentID: sessionID, title: "Background task" }),
|
||||
session({ id: blockingID, parentID: sessionID, title: "Foreground task" }),
|
||||
session({ id: backgroundID, parentID: Session.ID.make(sessionID), title: "Background task" }),
|
||||
session({ id: blockingID, parentID: Session.ID.make(sessionID), title: "Foreground task" }),
|
||||
],
|
||||
sessionStatus: {
|
||||
[sessionID]: { type: "busy" },
|
||||
@@ -203,12 +206,15 @@ test("separates blocking and already-backgrounded work into two rows", async ({
|
||||
page.locator('[data-timeline-part-id="call_shell_backgrounded"] [data-component="text-shimmer"]'),
|
||||
).toHaveAttribute("data-active", "true")
|
||||
|
||||
await timeline.transport.send({
|
||||
id: "evt_background_succeeded",
|
||||
created: Date.now(),
|
||||
type: "session.execution.succeeded",
|
||||
data: { sessionID: backgroundID },
|
||||
} as never)
|
||||
await timeline.transport.send(
|
||||
wire<OpenCodeEvent>({
|
||||
id: "evt_background_succeeded",
|
||||
created: Date.now(),
|
||||
type: "session.execution.succeeded",
|
||||
data: { sessionID: backgroundID },
|
||||
durable: { aggregateID: backgroundID, seq: 0, version: 1 },
|
||||
}),
|
||||
)
|
||||
await expect(backgroundCard.locator('[data-component="session-progress-indicator-v2"]')).toHaveCount(0)
|
||||
await expect(backgroundCard).toContainText("Background task (background)")
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import type { OpenCodeEvent, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { wire, type Wire } from "@/test-fixture"
|
||||
import { currentSession, mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
@@ -56,14 +57,15 @@ test("shows the not found fallback when the viewed session is deleted", async ({
|
||||
await openChildFromParent(page)
|
||||
await expectSessionTitle(page, taskDescription)
|
||||
|
||||
events.push({
|
||||
const deleted: Wire<OpenCodeEvent> = {
|
||||
id: "evt_session_deleted",
|
||||
created: 1700000003000,
|
||||
type: "session.deleted",
|
||||
durable: { aggregateID: childID, seq: 1, version: 2 },
|
||||
location: { directory },
|
||||
data: { sessionID: childID },
|
||||
})
|
||||
}
|
||||
events.push(wire<OpenCodeEvent>(deleted))
|
||||
|
||||
await expect(page.getByText("This session cannot be found")).toBeVisible()
|
||||
await expect(page.getByRole("button", { name: "Close Tab" })).toBeVisible()
|
||||
@@ -148,7 +150,7 @@ function childSession() {
|
||||
function parentMessages(): SessionMessageInfo[] {
|
||||
const userID = "msg_user_0001"
|
||||
const assistantID = "msg_assistant_0001"
|
||||
return [
|
||||
return wire<SessionMessageInfo[]>([
|
||||
{
|
||||
id: userID,
|
||||
type: "user",
|
||||
@@ -179,7 +181,7 @@ function parentMessages(): SessionMessageInfo[] {
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
])
|
||||
}
|
||||
|
||||
async function configurePage(page: Page) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createTwoFilesPatch } from "diff"
|
||||
import { wire } from "@/test-fixture"
|
||||
|
||||
const words = [
|
||||
"alpha",
|
||||
@@ -64,13 +65,13 @@ function id(prefix: string, value: number) {
|
||||
|
||||
function userMessage(_sessionID: string, index: number, textLength: number, diffs: unknown[] = []): SessionMessageInfo {
|
||||
const messageID = id("msg_user", index)
|
||||
return {
|
||||
return wire<SessionMessageInfo>({
|
||||
id: messageID,
|
||||
type: "user",
|
||||
time: { created: 1700000000000 + index * 10_000 },
|
||||
text: lorem(index, textLength),
|
||||
metadata: diffs.length ? { diffs: diffs as JsonValue } : undefined,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function assistantMessage(
|
||||
@@ -80,7 +81,7 @@ function assistantMessage(
|
||||
parts: MessagePart[],
|
||||
): SessionMessageInfo {
|
||||
const messageID = id("msg_assistant", index)
|
||||
return {
|
||||
return wire<SessionMessageInfo>({
|
||||
id: messageID,
|
||||
type: "assistant",
|
||||
time: { created: 1700000000000 + index * 10_000 + 1_000, completed: 1700000000000 + index * 10_000 + 8_000 },
|
||||
@@ -90,7 +91,7 @@ function assistantMessage(
|
||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
finish: "stop",
|
||||
content: parts.map(messageContent),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function messageContent(part: MessagePart): SessionMessageAssistant["content"][number] {
|
||||
@@ -140,10 +141,7 @@ function toolPart(
|
||||
const metadata =
|
||||
tool === "patch"
|
||||
? {
|
||||
files: [
|
||||
patchFile(index, "modified"),
|
||||
patchFile(index + 1, index % 2 === 0 ? "added" : "deleted"),
|
||||
],
|
||||
files: [patchFile(index, "modified"), patchFile(index + 1, index % 2 === 0 ? "added" : "deleted")],
|
||||
}
|
||||
: tool === "edit" || tool === "write"
|
||||
? { files: [fileDiff(String(input.path ?? `src/generated/file-${index}.ts`), index)] }
|
||||
@@ -214,14 +212,20 @@ function turn(index: number): SessionMessageInfo[] {
|
||||
: []),
|
||||
textPart(index, 2, 160 + (index % 6) * 90),
|
||||
...(index % 4 === 0
|
||||
? [toolPart(index, 3, "edit", { path: `src/generated/file-${index}.ts`, oldString: "before", newString: "after" }, 700)]
|
||||
? [
|
||||
toolPart(
|
||||
index,
|
||||
3,
|
||||
"edit",
|
||||
{ path: `src/generated/file-${index}.ts`, oldString: "before", newString: "after" },
|
||||
700,
|
||||
),
|
||||
]
|
||||
: []),
|
||||
...(index % 6 === 0
|
||||
? [toolPart(index, 7, "write", { path: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)]
|
||||
: []),
|
||||
...(index % 8 === 0
|
||||
? [toolPart(index, 8, "patch", { patchText: `Update generated patch ${index}` }, 620)]
|
||||
: []),
|
||||
...(index % 8 === 0 ? [toolPart(index, 8, "patch", { patchText: `Update generated patch ${index}` }, 620)] : []),
|
||||
...(index % 7 === 0 ? [toolPart(index, 4, "shell", { command: "bun typecheck" }, 620)] : []),
|
||||
...(index % 10 === 0 ? [toolPart(index, 9, "webfetch", { url: "https://example.com/docs/sample" }, 120)] : []),
|
||||
...(index % 11 === 0 ? [toolPart(index, 10, "websearch", { query: "sample movement notes" }, 240)] : []),
|
||||
|
||||
@@ -144,7 +144,7 @@ test.describe("smoke: session timeline", () => {
|
||||
await expectSessionTitle(page, fixture.expected.targetTitle)
|
||||
await switchTitlebarSession(page, fixture.sourceID, fixture.expected.sourceTitle)
|
||||
|
||||
const destination = fixture.messages[fixture.targetID].map((message) => message.id)
|
||||
const destination = fixture.messages[fixture.targetID].map((message) => String(message.id))
|
||||
const last = fixture.expected.targetMessageIDs.at(-1)!
|
||||
await page.evaluate(
|
||||
({ destination, last }) => {
|
||||
@@ -284,7 +284,7 @@ test.describe("smoke: session timeline", () => {
|
||||
await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${fixture.sourceID}`)
|
||||
await expectSessionTitle(page, fixture.expected.sourceTitle)
|
||||
const last = fixture.expected.targetMessageIDs.at(-1)!
|
||||
const destination = fixture.messages[fixture.targetID].map((message) => message.id)
|
||||
const destination = fixture.messages[fixture.targetID].map((message) => String(message.id))
|
||||
await page.evaluate(
|
||||
({ destination, last }) => {
|
||||
const ids = new Set(destination)
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionMessageUser } from "@opencode-ai/client/promise"
|
||||
import { extractPromptComments, extractPromptFromMessage } from "./prompt"
|
||||
import { wire } from "../test-fixture"
|
||||
|
||||
describe("extractPromptFromMessage", () => {
|
||||
test("restores multiple uploaded attachments", () => {
|
||||
const message = {
|
||||
const message = wire<SessionMessageUser>({
|
||||
id: "msg_1",
|
||||
type: "user",
|
||||
text: "check these",
|
||||
@@ -13,7 +14,7 @@ describe("extractPromptFromMessage", () => {
|
||||
{ data: "BBB", mime: "application/pdf", source: { type: "inline" }, name: "b.pdf" },
|
||||
],
|
||||
time: { created: 1 },
|
||||
} satisfies SessionMessageUser
|
||||
})
|
||||
|
||||
const result = extractPromptFromMessage(message)
|
||||
|
||||
@@ -36,7 +37,7 @@ describe("extractPromptFromMessage", () => {
|
||||
})
|
||||
|
||||
test("restores optimistic data URLs and review comments", () => {
|
||||
const message = {
|
||||
const message = wire<SessionMessageUser>({
|
||||
id: "msg_1",
|
||||
type: "user",
|
||||
text: "model text",
|
||||
@@ -60,7 +61,7 @@ describe("extractPromptFromMessage", () => {
|
||||
},
|
||||
],
|
||||
time: { created: 1 },
|
||||
} satisfies SessionMessageUser
|
||||
})
|
||||
|
||||
expect(extractPromptFromMessage(message)).toMatchObject([
|
||||
{ type: "text", content: "visible text" },
|
||||
@@ -72,7 +73,7 @@ describe("extractPromptFromMessage", () => {
|
||||
})
|
||||
|
||||
test("keeps the directory of a file mention without an at-sign", () => {
|
||||
const message = {
|
||||
const message = wire<SessionMessageUser>({
|
||||
id: "msg_1",
|
||||
type: "user",
|
||||
text: "inspect src/client.ts",
|
||||
@@ -86,7 +87,7 @@ describe("extractPromptFromMessage", () => {
|
||||
},
|
||||
],
|
||||
time: { created: 1 },
|
||||
} satisfies SessionMessageUser
|
||||
})
|
||||
|
||||
expect(extractPromptFromMessage(message)).toMatchObject([
|
||||
{ type: "text", content: "inspect " },
|
||||
@@ -95,25 +96,25 @@ describe("extractPromptFromMessage", () => {
|
||||
})
|
||||
|
||||
test("uses model text when presentation metadata is incomplete", () => {
|
||||
const message = {
|
||||
const message = wire<SessionMessageUser>({
|
||||
id: "msg_1",
|
||||
type: "user",
|
||||
text: "model text",
|
||||
metadata: { displayText: "partial display text" },
|
||||
time: { created: 1 },
|
||||
} satisfies SessionMessageUser
|
||||
})
|
||||
|
||||
expect(extractPromptFromMessage(message)[0]).toMatchObject({ type: "text", content: "model text" })
|
||||
})
|
||||
|
||||
test("restores skill mentions as structured Composer parts", () => {
|
||||
const message = {
|
||||
const message = wire<SessionMessageUser>({
|
||||
id: "msg_1",
|
||||
type: "user",
|
||||
text: "Use @review",
|
||||
skills: [{ id: "review", name: "Review", mention: { text: "@review", start: 4, end: 11 } }],
|
||||
time: { created: 1 },
|
||||
} satisfies SessionMessageUser
|
||||
})
|
||||
|
||||
expect(extractPromptFromMessage(message)).toMatchObject([
|
||||
{ type: "text", content: "Use " },
|
||||
|
||||
@@ -2,6 +2,8 @@ import { expect, test } from "bun:test"
|
||||
import { SESSION_TABS_REMOVED_EVENT, readSessionTabsRemovedDetail } from "@/shell/titlebar/session-events"
|
||||
import { archiveHomeSession } from "./archive"
|
||||
import type { ServerConnection } from "@/runtime/server/registry"
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import { wire } from "@/test-fixture"
|
||||
|
||||
const remote = "remote" as ServerConnection.Key
|
||||
|
||||
@@ -18,7 +20,7 @@ test("archiving a Home session removes its open titlebar tab", async () => {
|
||||
|
||||
await archiveHomeSession({
|
||||
server: remote,
|
||||
session: { id: "ses_1", location: { directory: "/workspace" } },
|
||||
session: wire<Pick<SessionInfo, "id" | "location">>({ id: "ses_1", location: { directory: "/workspace" } }),
|
||||
archive: async () => undefined,
|
||||
remove: () => {
|
||||
removed = true
|
||||
@@ -36,7 +38,7 @@ test("reports archive failures without removing the session", async () => {
|
||||
|
||||
await archiveHomeSession({
|
||||
server: remote,
|
||||
session: { id: "ses_1", location: { directory: "/workspace" } },
|
||||
session: wire<Pick<SessionInfo, "id" | "location">>({ id: "ses_1", location: { directory: "/workspace" } }),
|
||||
archive: async () => Promise.reject(failure),
|
||||
remove: () => {
|
||||
removed = true
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import { HOME_V2_SESSION_PAGE_LIMIT, loadHomeSessionIndex, parseHomeSessionIndex, retainHomeSessions } from "./index"
|
||||
import { wire, type Wire } from "@/test-fixture"
|
||||
|
||||
const session = (id: string, input: Partial<SessionInfo> = {}) =>
|
||||
({
|
||||
const session = (id: string, input: Partial<Wire<SessionInfo>> = {}) =>
|
||||
wire<SessionInfo>({
|
||||
id,
|
||||
projectID: "project",
|
||||
title: id,
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1, updated: 1 },
|
||||
location: { directory: "/repo" },
|
||||
...input,
|
||||
}) as SessionInfo
|
||||
})
|
||||
|
||||
describe("Home session index", () => {
|
||||
test("loads all pages", async () => {
|
||||
@@ -33,7 +36,7 @@ describe("Home session index", () => {
|
||||
session("root"),
|
||||
session("child", { parentID: "root" }),
|
||||
session("archived", { time: { created: 1, updated: 1, archived: 2 } }),
|
||||
]).map((item) => item.id),
|
||||
]).map((item) => String(item.id)),
|
||||
).toEqual(["root"])
|
||||
})
|
||||
|
||||
@@ -44,6 +47,6 @@ describe("Home session index", () => {
|
||||
1,
|
||||
now,
|
||||
)
|
||||
expect(result.map((item) => item.id)).toEqual(["b"])
|
||||
expect(result.map((item) => String(item.id))).toEqual(["b"])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,15 +2,18 @@ import { describe, expect, test } from "bun:test"
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import type { LocalProject } from "@/shell/state/layout"
|
||||
import { buildHomeSessionRecords } from "./records"
|
||||
import { wire } from "@/test-fixture"
|
||||
|
||||
const session = (id: string, directory: string, projectID: string) =>
|
||||
({
|
||||
wire<SessionInfo>({
|
||||
id,
|
||||
projectID,
|
||||
title: id,
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
location: { directory },
|
||||
time: { created: 1, updated: 1 },
|
||||
}) as SessionInfo
|
||||
})
|
||||
|
||||
describe("buildHomeSessionRecords", () => {
|
||||
const opened = { id: "project-a", worktree: "/repo/a", expanded: true } as LocalProject
|
||||
@@ -23,7 +26,7 @@ describe("buildHomeSessionRecords", () => {
|
||||
projects: () => [opened],
|
||||
})
|
||||
|
||||
expect(records.map((record) => record.session.id)).toEqual(["a", "b"])
|
||||
expect(records.map((record) => String(record.session.id))).toEqual(["a", "b"])
|
||||
expect(records[1]?.project).toMatchObject({ id: "project-b", worktree: "/repo/b", expanded: false })
|
||||
})
|
||||
|
||||
@@ -34,6 +37,6 @@ describe("buildHomeSessionRecords", () => {
|
||||
projects: () => [opened],
|
||||
})
|
||||
|
||||
expect(records.map((record) => record.session.id)).toEqual(["a"])
|
||||
expect(records.map((record) => String(record.session.id))).toEqual(["a"])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { PluginInfo } from "@opencode-ai/client"
|
||||
import { pluginLabels } from "./plugin"
|
||||
import { wire } from "../../test-fixture"
|
||||
|
||||
describe("pluginLabels", () => {
|
||||
test("omits built-in plugins", () => {
|
||||
const plugins: PluginInfo[] = [
|
||||
const plugins = wire<PluginInfo[]>([
|
||||
{ id: "opencode.internal", source: { type: "builtin" }, status: "active", tui: false },
|
||||
{ id: "package-plugin", source: { type: "package", package: "example" }, status: "active", tui: false },
|
||||
{ id: "local-plugin", source: { type: "local", path: "/tmp/plugin.ts" }, status: "active", tui: false },
|
||||
{ id: "sdk-plugin", source: { type: "sdk" }, status: "active", tui: false },
|
||||
]
|
||||
])
|
||||
|
||||
expect(pluginLabels(plugins)).toEqual(["package-plugin", "local-plugin", "sdk-plugin"])
|
||||
})
|
||||
|
||||
@@ -52,7 +52,7 @@ export function useProviders(directory: Accessor<string | undefined>) {
|
||||
.list()
|
||||
.filter((integration) => popularProviderSet.has(integration.id))
|
||||
.map((integration) => ({ id: integration.id, name: integration.name }))
|
||||
const seen = new Set(catalog.map((integration) => integration.id))
|
||||
const seen = new Set(catalog.map((integration) => String(integration.id)))
|
||||
return pipe(
|
||||
providers().all,
|
||||
Iterable.map(([, p]) => p),
|
||||
|
||||
@@ -2,8 +2,9 @@ import { describe, expect, test } from "bun:test"
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||
import { createRoot } from "solid-js"
|
||||
import { createOpenCodeEventSource } from "./client"
|
||||
import { wire } from "../../test-fixture"
|
||||
|
||||
const permission = {
|
||||
const permission = wire<Extract<OpenCodeEvent, { type: "permission.asked" }>>({
|
||||
id: "evt_permission",
|
||||
created: 1,
|
||||
type: "permission.asked",
|
||||
@@ -15,7 +16,7 @@ const permission = {
|
||||
resources: ["src/**"],
|
||||
source: { type: "tool", messageID: "msg_1", id: "call_1" },
|
||||
},
|
||||
} satisfies Extract<OpenCodeEvent, { type: "permission.asked" }>
|
||||
})
|
||||
|
||||
function setup() {
|
||||
return createRoot((dispose) => ({ ...createOpenCodeEventSource(), dispose }))
|
||||
@@ -45,11 +46,11 @@ describe("server event stream", () => {
|
||||
const other: OpenCodeEvent[] = []
|
||||
const all: OpenCodeEvent[] = []
|
||||
let workspaceID: string | undefined
|
||||
const global = {
|
||||
const global = wire<Extract<OpenCodeEvent, { type: "server.connected" }>>({
|
||||
id: "evt_connected",
|
||||
type: "server.connected",
|
||||
data: {},
|
||||
} satisfies Extract<OpenCodeEvent, { type: "server.connected" }>
|
||||
})
|
||||
|
||||
const repoEvents = server.event.location("/repo")
|
||||
repoEvents.on("permission.asked", (event) => {
|
||||
|
||||
@@ -52,7 +52,7 @@ describe("query keys", () => {
|
||||
|
||||
const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, projects, worktrees))
|
||||
|
||||
expect(result.map((project) => project.id)).toEqual(["a", "b"])
|
||||
expect(result.map((project) => String(project.id))).toEqual(["a", "b"])
|
||||
expect(result.map((project) => project.sandboxes)).toEqual([
|
||||
["/a/clone", "/a/copy"],
|
||||
["/b/clone", "/b/copy"],
|
||||
@@ -80,7 +80,7 @@ describe("query keys", () => {
|
||||
|
||||
const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, projects, worktrees))
|
||||
|
||||
expect(result.map((project) => ({ id: project.id, sandboxes: project.sandboxes }))).toEqual([
|
||||
expect(result.map((project) => ({ id: String(project.id), sandboxes: project.sandboxes }))).toEqual([
|
||||
{ id: "a", sandboxes: ["/a/copy"] },
|
||||
{ id: "b", sandboxes: [] },
|
||||
])
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { AgentListOutput, ModelListOutput, ProviderListOutput } from "@opencode-ai/client/promise"
|
||||
import { directoryKey, normalizeAgentList, normalizeProviderList } from "./utils"
|
||||
import { wire } from "../../../test-fixture"
|
||||
|
||||
describe("normalizeAgentList", () => {
|
||||
test("adapts current agents to the app agent shape", () => {
|
||||
const result = normalizeAgentList([
|
||||
{
|
||||
id: "build",
|
||||
name: "Build",
|
||||
mode: "primary",
|
||||
hidden: false,
|
||||
color: "primary",
|
||||
model: { id: "gpt-5", providerID: "openai", variant: "high" },
|
||||
request: { settings: { temperature: 0.2, topP: 0.9 }, headers: {}, body: {} },
|
||||
system: "Build software",
|
||||
permissions: [{ action: "read", resource: "*", effect: "allow" }],
|
||||
},
|
||||
] as AgentListOutput["data"])
|
||||
const result = normalizeAgentList(
|
||||
wire<AgentListOutput["data"]>([
|
||||
{
|
||||
id: "build",
|
||||
name: "Build",
|
||||
mode: "primary",
|
||||
hidden: false,
|
||||
color: "primary",
|
||||
model: { id: "gpt-5", providerID: "openai", variant: "high" },
|
||||
request: { settings: { temperature: 0.2, topP: 0.9 }, headers: {}, body: {} },
|
||||
system: "Build software",
|
||||
permissions: [{ action: "read", resource: "*", effect: "allow" }],
|
||||
},
|
||||
]),
|
||||
)
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
@@ -41,8 +44,10 @@ describe("normalizeAgentList", () => {
|
||||
describe("normalizeProviderList", () => {
|
||||
test("groups current models into the app provider catalog", () => {
|
||||
const result = normalizeProviderList(
|
||||
[{ id: "openai", name: "OpenAI", package: "@ai-sdk/openai" }] as ProviderListOutput["data"],
|
||||
[
|
||||
wire<ProviderListOutput["data"]>([
|
||||
{ id: "openai", name: "OpenAI", activation: "auto", package: "@ai-sdk/openai" },
|
||||
]),
|
||||
wire<ModelListOutput["data"]>([
|
||||
{
|
||||
id: "gpt-5",
|
||||
modelID: "gpt-5",
|
||||
@@ -69,7 +74,7 @@ describe("normalizeProviderList", () => {
|
||||
enabled: true,
|
||||
limit: { context: 1, output: 1 },
|
||||
},
|
||||
] as ModelListOutput["data"],
|
||||
]),
|
||||
)
|
||||
|
||||
expect(result.connected).toEqual(["openai"])
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionApi, SessionInfo, SessionListInput } from "@opencode-ai/client/promise"
|
||||
import { listAllSessions } from "./list"
|
||||
import { wire } from "../test-fixture"
|
||||
|
||||
describe("listAllSessions", () => {
|
||||
test("loads every page in server order and retains the query", async () => {
|
||||
@@ -18,7 +19,7 @@ describe("listAllSessions", () => {
|
||||
|
||||
const result = await listAllSessions(api, { directory: "/repo", order: "desc" })
|
||||
|
||||
expect(result.map((session) => session.id)).toEqual(["session-3", "session-2", "session-1"])
|
||||
expect(result.map((session) => String(session.id))).toEqual(["session-3", "session-2", "session-1"])
|
||||
expect(result[2]?.time.archived).toBe(2)
|
||||
expect(calls).toEqual([
|
||||
{ directory: "/repo", order: "desc", limit: 100, cursor: undefined },
|
||||
@@ -38,13 +39,13 @@ describe("listAllSessions", () => {
|
||||
|
||||
const result = await listAllSessions(api, { directory: "/repo", limit: 25 })
|
||||
|
||||
expect(result.map((session) => session.id)).toEqual(["session-1"])
|
||||
expect(result.map((session) => String(session.id))).toEqual(["session-1"])
|
||||
expect(cursors).toEqual([undefined, "terminal"])
|
||||
})
|
||||
})
|
||||
|
||||
function sessionInfo(id: string, archived = false) {
|
||||
return {
|
||||
return wire<SessionInfo>({
|
||||
id,
|
||||
projectID: "project-1",
|
||||
agent: "build",
|
||||
@@ -54,5 +55,5 @@ function sessionInfo(id: string, archived = false) {
|
||||
time: { created: 1, updated: 1, archived: archived ? 2 : undefined },
|
||||
title: id,
|
||||
location: { directory: "/repo" },
|
||||
} as SessionInfo
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,15 +8,12 @@ import {
|
||||
selectVisibleSessionUserMessages,
|
||||
} from "./session-domain"
|
||||
import { createSessionOwnership } from "./session-ownership"
|
||||
import { wire } from "../test-fixture"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
|
||||
const user = (id: string): SessionMessageUser => ({
|
||||
id,
|
||||
type: "user",
|
||||
text: id,
|
||||
time: { created: 0 },
|
||||
})
|
||||
const user = (id: string) => wire<SessionMessageUser>({ id, type: "user", text: id, time: { created: 0 } })
|
||||
|
||||
const assistant: SessionMessageAssistant = {
|
||||
const assistant = wire<SessionMessageAssistant>({
|
||||
id: "msg_2",
|
||||
type: "assistant",
|
||||
time: { created: 0 },
|
||||
@@ -25,7 +22,7 @@ const assistant: SessionMessageAssistant = {
|
||||
content: [],
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
}
|
||||
})
|
||||
|
||||
describe("session controller invariants", () => {
|
||||
test("normalizes file tabs once while preserving non-file tabs and order", () => {
|
||||
@@ -42,9 +39,11 @@ describe("session controller invariants", () => {
|
||||
const messages: SessionMessageInfo[] = [user("msg_a"), assistant, user("msg_b"), user("msg_c")]
|
||||
const users = selectSessionUserMessages(messages)
|
||||
|
||||
expect(users.map((message) => message.id)).toEqual(["msg_a", "msg_b", "msg_c"])
|
||||
expect(selectVisibleSessionUserMessages(users, "msg_b").map((message) => message.id)).toEqual(["msg_a"])
|
||||
expect(selectVisibleSessionUserMessages(users.slice(2), "msg_b")).toEqual([])
|
||||
expect(users.map((message) => String(message.id))).toEqual(["msg_a", "msg_b", "msg_c"])
|
||||
expect(
|
||||
selectVisibleSessionUserMessages(users, SessionMessage.ID.make("msg_b")).map((message) => String(message.id)),
|
||||
).toEqual(["msg_a"])
|
||||
expect(selectVisibleSessionUserMessages(users.slice(2), SessionMessage.ID.make("msg_b"))).toEqual([])
|
||||
expect(selectVisibleSessionUserMessages(users)).toBe(users)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,27 +1,36 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { FormInfo, PermissionRequest, SessionInfo } from "@opencode-ai/client/promise"
|
||||
import { sessionPermissionRequest, sessionQuestionForm } from "@/session/requests/session-request-tree"
|
||||
import { wire } from "@/test-fixture"
|
||||
|
||||
const session = (input: { id: string; parentID?: string }) =>
|
||||
({
|
||||
wire<SessionInfo>({
|
||||
id: input.id,
|
||||
parentID: input.parentID,
|
||||
}) as SessionInfo
|
||||
projectID: "project",
|
||||
title: input.id,
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 0, updated: 0 },
|
||||
location: { directory: "/repo" },
|
||||
})
|
||||
|
||||
const permission = (id: string, sessionID: string) =>
|
||||
({
|
||||
wire<PermissionRequest>({
|
||||
id,
|
||||
sessionID,
|
||||
}) as PermissionRequest
|
||||
action: "read",
|
||||
resources: [],
|
||||
})
|
||||
|
||||
const question = (id: string, sessionID: string) =>
|
||||
({
|
||||
wire<FormInfo>({
|
||||
id,
|
||||
sessionID,
|
||||
title: "Questions",
|
||||
metadata: { kind: "question" },
|
||||
fields: [{ key: "q0", type: "string" }],
|
||||
}) as FormInfo
|
||||
})
|
||||
|
||||
describe("sessionPermissionRequest", () => {
|
||||
test("prefers the current session permission", () => {
|
||||
@@ -31,7 +40,7 @@ describe("sessionPermissionRequest", () => {
|
||||
child: [permission("perm-child", "child")],
|
||||
}
|
||||
|
||||
expect(sessionPermissionRequest(sessions, permissions, "root")?.id).toBe("perm-root")
|
||||
expect(String(sessionPermissionRequest(sessions, permissions, "root")?.id)).toBe("perm-root")
|
||||
})
|
||||
|
||||
test("returns a nested child permission", () => {
|
||||
@@ -46,7 +55,7 @@ describe("sessionPermissionRequest", () => {
|
||||
other: [permission("perm-other", "other")],
|
||||
}
|
||||
|
||||
expect(sessionPermissionRequest(sessions, permissions, "root")?.id).toBe("perm-grand")
|
||||
expect(String(sessionPermissionRequest(sessions, permissions, "root")?.id)).toBe("perm-grand")
|
||||
})
|
||||
|
||||
test("returns undefined without a matching tree permission", () => {
|
||||
@@ -89,7 +98,7 @@ describe("sessionQuestionForm", () => {
|
||||
child: [question("q-child", "child")],
|
||||
}
|
||||
|
||||
expect(sessionQuestionForm(sessions, questions, "root")?.id).toBe("q-root")
|
||||
expect(String(sessionQuestionForm(sessions, questions, "root")?.id)).toBe("q-root")
|
||||
})
|
||||
|
||||
test("returns a nested child question", () => {
|
||||
@@ -102,7 +111,7 @@ describe("sessionQuestionForm", () => {
|
||||
grand: [question("q-grand", "grand")],
|
||||
}
|
||||
|
||||
expect(sessionQuestionForm(sessions, questions, "root")?.id).toBe("q-grand")
|
||||
expect(String(sessionQuestionForm(sessions, questions, "root")?.id)).toBe("q-grand")
|
||||
})
|
||||
|
||||
test("skips forms that are not questions", () => {
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
} from "@/session/composer/session-composer-region"
|
||||
import { SessionPanelFrame, SessionRouteFrame } from "@/session/session-frame"
|
||||
import type { FormInfo, PermissionRequest, SessionStatus } from "@opencode-ai/client/promise"
|
||||
import { Form } from "@opencode-ai/schema/form"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import type { SessionDocument } from "@opencode-ai/session-ui/document"
|
||||
import { CurrentSessionProviders, STORY_MODEL } from "@opencode-ai/session-ui/storybook"
|
||||
import { SessionTimeline } from "@opencode-ai/session-ui/timeline"
|
||||
@@ -103,7 +105,7 @@ export function SessionPreview(props: SessionPreviewProps) {
|
||||
props.request?.type === "question"
|
||||
? {
|
||||
type: "question",
|
||||
value: { ...props.request.value, id: `${props.request.value.id}:${revision}` },
|
||||
value: { ...props.request.value, id: Form.ID.make(`${props.request.value.id}:${revision}`) },
|
||||
}
|
||||
: props.request
|
||||
}
|
||||
@@ -213,7 +215,7 @@ function SessionSurfaceState(props: SessionPreviewProps & { onReset: () => void
|
||||
openParent: () => setState("activity", "Opened the parent Session locally"),
|
||||
setPromptRef() {},
|
||||
setDockRef() {},
|
||||
parentID: () => props.child?.parentID,
|
||||
parentID: () => (props.child ? Session.ID.make(props.child.parentID) : undefined),
|
||||
child: () => !!props.child,
|
||||
showComposer: () => true,
|
||||
handoffPrompt: () => undefined,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionInboxInfo, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import { visibleTimelineMessages } from "./controller-projection"
|
||||
import { wire } from "../../test-fixture"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
|
||||
const messages = [
|
||||
const messages = wire<SessionMessageInfo[]>([
|
||||
{ id: "msg_1", type: "user", text: "first", time: { created: 1 } },
|
||||
{
|
||||
id: "msg_2",
|
||||
@@ -14,11 +16,11 @@ const messages = [
|
||||
},
|
||||
{ id: "msg_3", type: "user", text: "queued", time: { created: 3 } },
|
||||
{ id: "msg_4", type: "user", text: "reverted", time: { created: 4 } },
|
||||
] satisfies SessionMessageInfo[]
|
||||
])
|
||||
|
||||
describe("visibleTimelineMessages", () => {
|
||||
test("hides queued inputs until delivery", () => {
|
||||
const pending = [
|
||||
const pending = wire<SessionInboxInfo[]>([
|
||||
{
|
||||
id: "msg_3",
|
||||
sessionID: "ses_1",
|
||||
@@ -27,17 +29,19 @@ describe("visibleTimelineMessages", () => {
|
||||
delivery: "queue",
|
||||
payload: { text: "queued" },
|
||||
},
|
||||
] satisfies SessionInboxInfo[]
|
||||
])
|
||||
|
||||
expect(visibleTimelineMessages(messages, pending).map((message) => message.id)).toEqual(["msg_1", "msg_2", "msg_4"])
|
||||
expect(visibleTimelineMessages(messages, pending).map((message) => String(message.id))).toEqual([
|
||||
"msg_1",
|
||||
"msg_2",
|
||||
"msg_4",
|
||||
])
|
||||
})
|
||||
|
||||
test("hides the staged revert boundary and later messages", () => {
|
||||
expect(visibleTimelineMessages(messages, [], "msg_4").map((message) => message.id)).toEqual([
|
||||
"msg_1",
|
||||
"msg_2",
|
||||
"msg_3",
|
||||
])
|
||||
expect(visibleTimelineMessages(messages, [], "msg_0")).toEqual([])
|
||||
expect(
|
||||
visibleTimelineMessages(messages, [], SessionMessage.ID.make("msg_4")).map((message) => String(message.id)),
|
||||
).toEqual(["msg_1", "msg_2", "msg_3"])
|
||||
expect(visibleTimelineMessages(messages, [], SessionMessage.ID.make("msg_0"))).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,25 +1,30 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionMessageAssistant, SessionMessageInfo, SessionMessageUser } from "@opencode-ai/client/promise"
|
||||
import { loadOlderTimeline, selectUserMessages, selectVisibleUserMessages } from "./model"
|
||||
import { wire } from "../../test-fixture"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
|
||||
const user = (id: string): SessionMessageUser => ({ id, type: "user", text: id, time: { created: 1 } })
|
||||
const assistant = (id: string): SessionMessageAssistant => ({
|
||||
id,
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: [],
|
||||
time: { created: 1 },
|
||||
})
|
||||
const user = (id: string) => wire<SessionMessageUser>({ id, type: "user", text: id, time: { created: 1 } })
|
||||
const assistant = (id: string) =>
|
||||
wire<SessionMessageAssistant>({
|
||||
id,
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: [],
|
||||
time: { created: 1 },
|
||||
})
|
||||
|
||||
describe("timeline model", () => {
|
||||
test("selects users and applies the revert boundary", () => {
|
||||
const messages: SessionMessageInfo[] = [user("msg_a"), assistant("msg_ab"), user("msg_b"), user("msg_c")]
|
||||
const users = selectUserMessages(messages)
|
||||
|
||||
expect(users.map((message) => message.id)).toEqual(["msg_a", "msg_b", "msg_c"])
|
||||
expect(selectVisibleUserMessages(users, "msg_b").map((message) => message.id)).toEqual(["msg_a"])
|
||||
expect(selectVisibleUserMessages(users.slice(2), "msg_b")).toEqual([])
|
||||
expect(users.map((message) => String(message.id))).toEqual(["msg_a", "msg_b", "msg_c"])
|
||||
expect(
|
||||
selectVisibleUserMessages(users, SessionMessage.ID.make("msg_b")).map((message) => String(message.id)),
|
||||
).toEqual(["msg_a"])
|
||||
expect(selectVisibleUserMessages(users.slice(2), SessionMessage.ID.make("msg_b"))).toEqual([])
|
||||
expect(selectVisibleUserMessages(users)).toBe(users)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
import type { ModelRef, SessionMessageInfo, SessionStatus } from "@opencode-ai/client/promise"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
import { reuseTimelineRows, Timeline, TimelineRow } from "@opencode-ai/session-ui/timeline/projection"
|
||||
import { createMemo, type Accessor } from "solid-js"
|
||||
|
||||
export { reuseTimelineRows } from "@opencode-ai/session-ui/timeline/projection"
|
||||
|
||||
const emptyModel: ModelRef = { id: Model.ID.make(""), providerID: Provider.ID.make("") }
|
||||
|
||||
export function createTimelineProjection(input: {
|
||||
sessionMessages: Accessor<SessionMessageInfo[]>
|
||||
status: Accessor<SessionStatus>
|
||||
showReasoningSummaries: Accessor<boolean>
|
||||
}) {
|
||||
const sessionMessageByID = createMemo(
|
||||
() => new Map(input.sessionMessages().map((message) => [message.id, message] as const)),
|
||||
() => new Map<string, SessionMessageInfo>(input.sessionMessages().map((message) => [message.id, message])),
|
||||
)
|
||||
const userContextByID = createMemo(() => {
|
||||
const result = new Map<string, { agent: string; model: ModelRef }>()
|
||||
let agent = ""
|
||||
let model: ModelRef = { id: "", providerID: "" }
|
||||
let model: ModelRef = emptyModel
|
||||
let userID: string | undefined
|
||||
input.sessionMessages().forEach((message) => {
|
||||
if (message.type === "agent-switched") agent = message.agent
|
||||
@@ -42,9 +46,10 @@ export function createTimelineProjection(input: {
|
||||
localModelID &&
|
||||
typeof localModel.providerID === "string"
|
||||
? {
|
||||
id: localModelID,
|
||||
providerID: localModel.providerID,
|
||||
variant: typeof localModel.variant === "string" ? localModel.variant : undefined,
|
||||
id: Model.ID.make(localModelID),
|
||||
providerID: Provider.ID.make(localModel.providerID),
|
||||
variant:
|
||||
typeof localModel.variant === "string" ? Model.VariantID.make(localModel.variant) : undefined,
|
||||
}
|
||||
: model,
|
||||
})
|
||||
|
||||
@@ -23,7 +23,9 @@ export const useSessionHashScroll = (input: {
|
||||
consumePendingMessage: (key: string) => string | undefined
|
||||
}) => {
|
||||
const visibleUserMessages = createMemo(() => input.visibleUserMessages())
|
||||
const messageById = createMemo(() => new Map(visibleUserMessages().map((m) => [m.id, m])))
|
||||
const messageById = createMemo(
|
||||
() => new Map<string, SessionMessageUser>(visibleUserMessages().map((message) => [message.id, message])),
|
||||
)
|
||||
let pendingKey = ""
|
||||
let clearing = false
|
||||
|
||||
|
||||
@@ -17,11 +17,12 @@ import {
|
||||
} from "./helpers"
|
||||
import { pathKey } from "@/workspaces/path-key"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { wire, type Wire } from "@/test-fixture"
|
||||
|
||||
const serverKey = ServerConnection.Key.make
|
||||
|
||||
const session = (input: Partial<SessionInfo> & Pick<SessionInfo, "id"> & { directory: string }) =>
|
||||
({
|
||||
const session = (input: Partial<Wire<SessionInfo>> & Pick<Wire<SessionInfo>, "id"> & { directory: string }) =>
|
||||
wire<SessionInfo>({
|
||||
projectID: "project",
|
||||
title: "",
|
||||
cost: 0,
|
||||
@@ -30,8 +31,7 @@ const session = (input: Partial<SessionInfo> & Pick<SessionInfo, "id"> & { direc
|
||||
time: { created: 0, updated: 0, archived: undefined },
|
||||
...input,
|
||||
location: { directory: input.directory },
|
||||
directory: undefined,
|
||||
}) as SessionInfo
|
||||
})
|
||||
|
||||
describe("layout workspace helpers", () => {
|
||||
test("normalizes trailing slash in workspace key", () => {
|
||||
@@ -73,7 +73,7 @@ describe("layout workspace helpers", () => {
|
||||
120_000,
|
||||
)
|
||||
|
||||
expect(result?.id).toBe("workspace")
|
||||
expect(String(result?.id)).toBe("workspace")
|
||||
})
|
||||
|
||||
test("sorts recent sessions by persisted update time instead of id", () => {
|
||||
@@ -88,7 +88,7 @@ describe("layout workspace helpers", () => {
|
||||
3,
|
||||
)
|
||||
|
||||
expect(result.map((item) => item.id)).toEqual(["ses_a", "ses_z"])
|
||||
expect(result.map((item) => String(item.id))).toEqual(["ses_a", "ses_z"])
|
||||
})
|
||||
|
||||
test("uses id only to break equal session timestamps", () => {
|
||||
@@ -97,7 +97,7 @@ describe("layout workspace helpers", () => {
|
||||
session({ id: "ses_a", directory: "/workspace", time: { created: 1, updated: 2, archived: undefined } }),
|
||||
]
|
||||
|
||||
expect(sessions.sort(compareSessionTime).map((item) => item.id)).toEqual(["ses_a", "ses_z"])
|
||||
expect(sessions.sort(compareSessionTime).map((item) => String(item.id))).toEqual(["ses_a", "ses_z"])
|
||||
})
|
||||
|
||||
test("detects project permissions with a filter", () => {
|
||||
@@ -151,7 +151,7 @@ describe("layout workspace helpers", () => {
|
||||
120_000,
|
||||
)
|
||||
|
||||
expect(result?.id).toBe("root")
|
||||
expect(String(result?.id)).toBe("root")
|
||||
})
|
||||
|
||||
test("finds the direct child on the active session path", () => {
|
||||
@@ -161,8 +161,8 @@ describe("layout workspace helpers", () => {
|
||||
session({ id: "leaf", directory: "/workspace", parentID: "child" }),
|
||||
]
|
||||
|
||||
expect(childSessionOnPath(list, "root", "leaf")?.id).toBe("child")
|
||||
expect(childSessionOnPath(list, "child", "leaf")?.id).toBe("leaf")
|
||||
expect(String(childSessionOnPath(list, "root", "leaf")?.id)).toBe("child")
|
||||
expect(String(childSessionOnPath(list, "child", "leaf")?.id)).toBe("leaf")
|
||||
expect(childSessionOnPath(list, "root", "root")).toBeUndefined()
|
||||
expect(childSessionOnPath(list, "root", "other")).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -35,7 +35,7 @@ export function hasProjectPermissions<T>(
|
||||
|
||||
export const childSessionOnPath = (sessions: SessionInfo[] | undefined, rootID: string, activeID?: string) => {
|
||||
if (!activeID || activeID === rootID) return
|
||||
const map = new Map((sessions ?? []).map((session) => [session.id, session]))
|
||||
const map = new Map<string, SessionInfo>((sessions ?? []).map((session) => [session.id, session]))
|
||||
let id = activeID
|
||||
|
||||
while (id) {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Brand } from "effect"
|
||||
|
||||
export type Wire<T> =
|
||||
T extends Brand.Brand<string>
|
||||
? Brand.Brand.Unbranded<T>
|
||||
: T extends ReadonlyArray<infer Item>
|
||||
? Wire<Item>[]
|
||||
: T extends object
|
||||
? { [Key in keyof T]: Wire<T[Key]> }
|
||||
: T
|
||||
|
||||
export function wire<T>(value: Wire<T>): T {
|
||||
return value as T
|
||||
}
|
||||
@@ -1,15 +1,17 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||
import { invalidateFromWatcher } from "./watcher"
|
||||
import { wire } from "../../test-fixture"
|
||||
|
||||
type FilesystemEvent = Extract<OpenCodeEvent, { type: "filesystem.changed" }>
|
||||
|
||||
const filesystemEvent = (file: string, event: FilesystemEvent["data"]["event"]): FilesystemEvent => ({
|
||||
id: `evt_${file}`,
|
||||
created: 1,
|
||||
type: "filesystem.changed",
|
||||
data: { file, event },
|
||||
})
|
||||
const filesystemEvent = (file: string, event: FilesystemEvent["data"]["event"]) =>
|
||||
wire<FilesystemEvent>({
|
||||
id: `evt_${file}`,
|
||||
created: 1,
|
||||
type: "filesystem.changed",
|
||||
data: { file, event },
|
||||
})
|
||||
|
||||
describe("file watcher invalidation", () => {
|
||||
test("reloads open files and refreshes loaded parent on add", () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import type { LocationGetOutput, LocationRef } from "@opencode-ai/client/promise"
|
||||
import type { Workspace } from "@opencode-ai/schema/workspace"
|
||||
import { type Accessor, createEffect, createMemo, createSignal } from "solid-js"
|
||||
import { type LocationContext, useServerSDK } from "@/runtime/server/client"
|
||||
import { useData } from "@/runtime/server/current"
|
||||
@@ -13,13 +14,19 @@ export type WorkspaceLocation = LocationContext & {
|
||||
|
||||
const context = createSimpleContext({
|
||||
name: "Location",
|
||||
init: (props: { directory: string | Accessor<string>; workspaceID?: string | Accessor<string | undefined> }) => {
|
||||
init: (props: {
|
||||
directory: string | Accessor<string>
|
||||
workspaceID?: Workspace.ID | Accessor<Workspace.ID | undefined>
|
||||
}) => {
|
||||
const serverSDK = useServerSDK()
|
||||
const data = useData()
|
||||
const ref = createMemo(() => ({
|
||||
directory: typeof props.directory === "function" ? props.directory() : props.directory,
|
||||
workspaceID: typeof props.workspaceID === "function" ? props.workspaceID() : props.workspaceID,
|
||||
}))
|
||||
const ref = createMemo<LocationRef>(() => {
|
||||
const workspaceID = typeof props.workspaceID === "function" ? props.workspaceID() : props.workspaceID
|
||||
return {
|
||||
directory: typeof props.directory === "function" ? props.directory() : props.directory,
|
||||
workspaceID,
|
||||
}
|
||||
})
|
||||
const current = createMemo(() => data.location.info(ref()))
|
||||
const [error, setError] = createSignal<{ readonly location: LocationRef; readonly cause: unknown }>()
|
||||
let generation = 0
|
||||
|
||||
@@ -120,7 +120,7 @@ test("groups nested non-archived workspace sessions by latest activity", () => {
|
||||
],
|
||||
"/workspace",
|
||||
)
|
||||
expect(sessions.map((item) => item.id)).toEqual(["nested", "old"])
|
||||
expect(sessions.map((item) => String(item.id))).toEqual(["nested", "old"])
|
||||
})
|
||||
|
||||
test("merges workspace placement by freshness with authoritative server ties", () => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type SessionMessageInfo,
|
||||
type SkillInfo,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
|
||||
import type {
|
||||
AgentSideConnection,
|
||||
@@ -286,7 +287,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
||||
)
|
||||
if (!model?.variants.some((variant) => variant.id === params.value))
|
||||
throw new ACPError.InvalidEffortError({ effort: params.value })
|
||||
state.model = { ...state.model, variant: params.value }
|
||||
state.model = { ...state.model, variant: Model.VariantID.make(params.value) }
|
||||
await input.client.session.switchModel({ sessionID: state.id, model: state.model })
|
||||
break
|
||||
}
|
||||
@@ -456,7 +457,11 @@ function requireModel(catalog: Catalog, modelID: string): ModelRef {
|
||||
if (!model) throw new ACPError.InvalidModelError({ providerId: selected.model.providerID, modelId: modelID })
|
||||
if (selected.variant && !model.variants.some((variant) => variant.id === selected.variant))
|
||||
throw new ACPError.InvalidEffortError({ effort: selected.variant })
|
||||
return { providerID: model.providerID, id: model.id, variant: selected.variant }
|
||||
return {
|
||||
providerID: model.providerID,
|
||||
id: model.id,
|
||||
variant: selected.variant === undefined ? undefined : Model.VariantID.make(selected.variant),
|
||||
}
|
||||
}
|
||||
|
||||
async function selectMode(client: OpenCodeClient, state: Attached, modeID: string) {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Service, type Endpoint } from "@opencode-ai/client/effect/service"
|
||||
import { ClientError, OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
import type { MiniFrontendInput } from "@opencode-ai/tui/mini"
|
||||
import { setTimeout } from "node:timers/promises"
|
||||
import { readStdin } from "./util/io"
|
||||
@@ -94,7 +96,11 @@ export async function runMini(input: MiniCommandInput) {
|
||||
agent: next.agent,
|
||||
environment,
|
||||
model: next.model
|
||||
? { providerID: next.model.providerID, id: next.model.modelID, variant: next.variant }
|
||||
? {
|
||||
providerID: Provider.ID.make(next.model.providerID),
|
||||
id: Model.ID.make(next.model.modelID),
|
||||
variant: next.variant === undefined ? undefined : Model.VariantID.make(next.variant),
|
||||
}
|
||||
: undefined,
|
||||
prepare,
|
||||
signal,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Service, type Endpoint } from "@opencode-ai/client/effect/service"
|
||||
import { OpenCode, type OpenCodeClient, type SessionMessageAssistantTool } from "@opencode-ai/client/promise"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { open } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
@@ -112,7 +113,12 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End
|
||||
? {
|
||||
providerID: selected.providerID,
|
||||
id: selected.id,
|
||||
variant: options.variant ?? ("variant" in selected ? selected.variant : undefined),
|
||||
variant:
|
||||
options.variant !== undefined
|
||||
? Model.VariantID.make(options.variant)
|
||||
: "variant" in selected
|
||||
? selected.variant
|
||||
: undefined,
|
||||
}
|
||||
: undefined
|
||||
if ((options.variant ?? explicit?.variant) && !model)
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { AgentSideConnection } from "@agentclientprotocol/sdk"
|
||||
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import { resolve } from "node:path"
|
||||
import { replayMessages, streamTurn, type ChildSessionUpdate, type TurnControl } from "../../src/acp/event"
|
||||
import { wire } from "../fixture/wire"
|
||||
import { createSseFixture, durableEvent, ephemeralEvent, withTimeout } from "./sse-fixture"
|
||||
|
||||
type SessionUpdateParams = Parameters<AgentSideConnection["sessionUpdate"]>[0]
|
||||
@@ -15,51 +16,66 @@ describe("acp event behavior", () => {
|
||||
const fixture = createSseFixture({
|
||||
onPrompt({ id, send }) {
|
||||
send(
|
||||
ephemeralEvent("session.text.delta", {
|
||||
sessionID: "ses_a",
|
||||
assistantMessageID: "msg_before",
|
||||
ordinal: 0,
|
||||
delta: "before admission",
|
||||
}),
|
||||
ephemeralEvent(
|
||||
"session.text.delta",
|
||||
wire({
|
||||
sessionID: "ses_a",
|
||||
assistantMessageID: "msg_before",
|
||||
ordinal: 0,
|
||||
delta: "before admission",
|
||||
}),
|
||||
),
|
||||
)
|
||||
send(durableEvent("session.inbox.delivered", { sessionID: "ses_b", inboxID: id }))
|
||||
send(durableEvent("session.inbox.delivered", { sessionID: "ses_a", inboxID: "input_other" }))
|
||||
send(durableEvent("session.inbox.delivered", wire({ sessionID: "ses_b", inboxID: id })))
|
||||
send(durableEvent("session.inbox.delivered", wire({ sessionID: "ses_a", inboxID: "input_other" })))
|
||||
send(
|
||||
ephemeralEvent("session.text.delta", {
|
||||
sessionID: "ses_a",
|
||||
assistantMessageID: "msg_wrong_input",
|
||||
ordinal: 0,
|
||||
delta: "wrong input",
|
||||
}),
|
||||
ephemeralEvent(
|
||||
"session.text.delta",
|
||||
wire({
|
||||
sessionID: "ses_a",
|
||||
assistantMessageID: "msg_wrong_input",
|
||||
ordinal: 0,
|
||||
delta: "wrong input",
|
||||
}),
|
||||
),
|
||||
)
|
||||
send(durableEvent("session.inbox.delivered", { sessionID: "ses_a", inboxID: id }))
|
||||
send(durableEvent("session.inbox.delivered", wire({ sessionID: "ses_a", inboxID: id })))
|
||||
send(
|
||||
ephemeralEvent("session.text.delta", {
|
||||
sessionID: "ses_b",
|
||||
assistantMessageID: "msg_b",
|
||||
ordinal: 0,
|
||||
delta: "other session",
|
||||
}),
|
||||
ephemeralEvent(
|
||||
"session.text.delta",
|
||||
wire({
|
||||
sessionID: "ses_b",
|
||||
assistantMessageID: "msg_b",
|
||||
ordinal: 0,
|
||||
delta: "other session",
|
||||
}),
|
||||
),
|
||||
)
|
||||
send(
|
||||
ephemeralEvent("session.text.delta", {
|
||||
sessionID: "ses_a",
|
||||
assistantMessageID: "msg_a",
|
||||
ordinal: 0,
|
||||
delta: "accepted",
|
||||
}),
|
||||
ephemeralEvent(
|
||||
"session.text.delta",
|
||||
wire({
|
||||
sessionID: "ses_a",
|
||||
assistantMessageID: "msg_a",
|
||||
ordinal: 0,
|
||||
delta: "accepted",
|
||||
}),
|
||||
),
|
||||
)
|
||||
send(
|
||||
durableEvent("session.step.ended", {
|
||||
sessionID: "ses_a",
|
||||
assistantMessageID: "msg_a",
|
||||
finish: "stop",
|
||||
cost: 0,
|
||||
tokens: tokens(),
|
||||
}),
|
||||
durableEvent(
|
||||
"session.step.ended",
|
||||
wire({
|
||||
sessionID: "ses_a",
|
||||
assistantMessageID: "msg_a",
|
||||
finish: "stop",
|
||||
cost: 0,
|
||||
tokens: tokens(),
|
||||
}),
|
||||
),
|
||||
)
|
||||
send(durableEvent("session.execution.succeeded", { sessionID: "ses_b" }))
|
||||
send(durableEvent("session.execution.succeeded", { sessionID: "ses_a" }))
|
||||
send(durableEvent("session.execution.succeeded", wire({ sessionID: "ses_b" })))
|
||||
send(durableEvent("session.execution.succeeded", wire({ sessionID: "ses_a" })))
|
||||
},
|
||||
})
|
||||
|
||||
@@ -99,41 +115,53 @@ describe("acp event behavior", () => {
|
||||
const updates: SessionUpdateParams[] = []
|
||||
const fixture = createSseFixture({
|
||||
async onPrompt({ id, send }) {
|
||||
send(durableEvent("session.inbox.delivered", { sessionID: "ses_order", inboxID: id }))
|
||||
send(durableEvent("session.inbox.delivered", wire({ sessionID: "ses_order", inboxID: id })))
|
||||
send(
|
||||
ephemeralEvent("session.reasoning.delta", {
|
||||
sessionID: "ses_order",
|
||||
assistantMessageID: "msg_order",
|
||||
ordinal: 0,
|
||||
delta: "think-1",
|
||||
}),
|
||||
ephemeralEvent(
|
||||
"session.reasoning.delta",
|
||||
wire({
|
||||
sessionID: "ses_order",
|
||||
assistantMessageID: "msg_order",
|
||||
ordinal: 0,
|
||||
delta: "think-1",
|
||||
}),
|
||||
),
|
||||
)
|
||||
send(
|
||||
ephemeralEvent("session.text.delta", {
|
||||
sessionID: "ses_order",
|
||||
assistantMessageID: "msg_order",
|
||||
ordinal: 1,
|
||||
delta: "answer",
|
||||
}),
|
||||
ephemeralEvent(
|
||||
"session.text.delta",
|
||||
wire({
|
||||
sessionID: "ses_order",
|
||||
assistantMessageID: "msg_order",
|
||||
ordinal: 1,
|
||||
delta: "answer",
|
||||
}),
|
||||
),
|
||||
)
|
||||
send(
|
||||
ephemeralEvent("session.reasoning.delta", {
|
||||
sessionID: "ses_order",
|
||||
assistantMessageID: "msg_order",
|
||||
ordinal: 2,
|
||||
delta: "think-2",
|
||||
}),
|
||||
ephemeralEvent(
|
||||
"session.reasoning.delta",
|
||||
wire({
|
||||
sessionID: "ses_order",
|
||||
assistantMessageID: "msg_order",
|
||||
ordinal: 2,
|
||||
delta: "think-2",
|
||||
}),
|
||||
),
|
||||
)
|
||||
send(
|
||||
durableEvent("session.step.ended", {
|
||||
sessionID: "ses_order",
|
||||
assistantMessageID: "msg_order",
|
||||
finish: "stop",
|
||||
cost: 0,
|
||||
tokens: tokens(),
|
||||
}),
|
||||
durableEvent(
|
||||
"session.step.ended",
|
||||
wire({
|
||||
sessionID: "ses_order",
|
||||
assistantMessageID: "msg_order",
|
||||
finish: "stop",
|
||||
cost: 0,
|
||||
tokens: tokens(),
|
||||
}),
|
||||
),
|
||||
)
|
||||
send(durableEvent("session.execution.succeeded", { sessionID: "ses_order" }))
|
||||
send(durableEvent("session.execution.succeeded", wire({ sessionID: "ses_order" })))
|
||||
await releaseSubmit.promise
|
||||
},
|
||||
})
|
||||
@@ -195,43 +223,55 @@ describe("acp event behavior", () => {
|
||||
const updates: SessionUpdateParams[] = []
|
||||
const fixture = createSseFixture({
|
||||
onPrompt({ id, send }) {
|
||||
send(durableEvent("session.inbox.delivered", { sessionID: "ses_parent", inboxID: id }))
|
||||
send(durableEvent("session.inbox.delivered", wire({ sessionID: "ses_parent", inboxID: id })))
|
||||
send(
|
||||
durableEvent("session.created", {
|
||||
sessionID: "ses_child",
|
||||
...childSession("ses_child", "ses_parent", "Explore code"),
|
||||
}),
|
||||
durableEvent(
|
||||
"session.created",
|
||||
wire({
|
||||
sessionID: "ses_child",
|
||||
...childSession("ses_child", "ses_parent", "Explore code"),
|
||||
}),
|
||||
),
|
||||
)
|
||||
send(durableEvent("session.execution.started", { sessionID: "ses_child" }))
|
||||
send(durableEvent("session.execution.started", wire({ sessionID: "ses_child" })))
|
||||
send(
|
||||
durableEvent("session.tool.input.started", {
|
||||
sessionID: "ses_child",
|
||||
assistantMessageID: "msg_child",
|
||||
id: "call_read",
|
||||
name: "read",
|
||||
}),
|
||||
durableEvent(
|
||||
"session.tool.input.started",
|
||||
wire({
|
||||
sessionID: "ses_child",
|
||||
assistantMessageID: "msg_child",
|
||||
id: "call_read",
|
||||
name: "read",
|
||||
}),
|
||||
),
|
||||
)
|
||||
send(
|
||||
durableEvent("session.tool.called", {
|
||||
sessionID: "ses_child",
|
||||
assistantMessageID: "msg_child",
|
||||
id: "call_read",
|
||||
input: { path: "/workspace/src/index.ts" },
|
||||
executed: false,
|
||||
}),
|
||||
durableEvent(
|
||||
"session.tool.called",
|
||||
wire({
|
||||
sessionID: "ses_child",
|
||||
assistantMessageID: "msg_child",
|
||||
id: "call_read",
|
||||
input: { path: "/workspace/src/index.ts" },
|
||||
executed: false,
|
||||
}),
|
||||
),
|
||||
)
|
||||
send(
|
||||
durableEvent("session.tool.success", {
|
||||
sessionID: "ses_child",
|
||||
assistantMessageID: "msg_child",
|
||||
id: "call_read",
|
||||
metadata: {},
|
||||
content: [{ type: "text", text: "source" }],
|
||||
executed: true,
|
||||
}),
|
||||
durableEvent(
|
||||
"session.tool.success",
|
||||
wire({
|
||||
sessionID: "ses_child",
|
||||
assistantMessageID: "msg_child",
|
||||
id: "call_read",
|
||||
metadata: {},
|
||||
content: [{ type: "text", text: "source" }],
|
||||
executed: true,
|
||||
}),
|
||||
),
|
||||
)
|
||||
send(durableEvent("session.execution.succeeded", { sessionID: "ses_child" }))
|
||||
send(durableEvent("session.execution.succeeded", { sessionID: "ses_parent" }))
|
||||
send(durableEvent("session.execution.succeeded", wire({ sessionID: "ses_child" })))
|
||||
send(durableEvent("session.execution.succeeded", wire({ sessionID: "ses_parent" })))
|
||||
},
|
||||
})
|
||||
|
||||
@@ -276,14 +316,17 @@ describe("acp event behavior", () => {
|
||||
const completed = Promise.withResolvers<void>()
|
||||
const fixture = createSseFixture({
|
||||
onPrompt({ id, send }) {
|
||||
send(durableEvent("session.inbox.delivered", { sessionID: "ses_parent", inboxID: id }))
|
||||
send(durableEvent("session.inbox.delivered", wire({ sessionID: "ses_parent", inboxID: id })))
|
||||
send(
|
||||
durableEvent("session.created", {
|
||||
sessionID: "ses_background",
|
||||
...childSession("ses_background", "ses_parent", "Background research"),
|
||||
}),
|
||||
durableEvent(
|
||||
"session.created",
|
||||
wire({
|
||||
sessionID: "ses_background",
|
||||
...childSession("ses_background", "ses_parent", "Background research"),
|
||||
}),
|
||||
),
|
||||
)
|
||||
send(durableEvent("session.execution.succeeded", { sessionID: "ses_parent" }))
|
||||
send(durableEvent("session.execution.succeeded", wire({ sessionID: "ses_parent" })))
|
||||
},
|
||||
})
|
||||
|
||||
@@ -301,41 +344,53 @@ describe("acp event behavior", () => {
|
||||
expect(response.stopReason).toBe("end_turn")
|
||||
|
||||
fixture.send(
|
||||
durableEvent("session.created", {
|
||||
sessionID: "ses_future",
|
||||
...childSession("ses_future", "ses_parent", "Later turn child"),
|
||||
}),
|
||||
durableEvent(
|
||||
"session.created",
|
||||
wire({
|
||||
sessionID: "ses_future",
|
||||
...childSession("ses_future", "ses_parent", "Later turn child"),
|
||||
}),
|
||||
),
|
||||
)
|
||||
fixture.send(durableEvent("session.execution.started", { sessionID: "ses_future" }))
|
||||
fixture.send(durableEvent("session.execution.started", { sessionID: "ses_background" }))
|
||||
fixture.send(durableEvent("session.execution.started", wire({ sessionID: "ses_future" })))
|
||||
fixture.send(durableEvent("session.execution.started", wire({ sessionID: "ses_background" })))
|
||||
fixture.send(
|
||||
durableEvent("session.tool.input.started", {
|
||||
sessionID: "ses_background",
|
||||
assistantMessageID: "msg_background",
|
||||
id: "call_shell",
|
||||
name: "shell",
|
||||
}),
|
||||
durableEvent(
|
||||
"session.tool.input.started",
|
||||
wire({
|
||||
sessionID: "ses_background",
|
||||
assistantMessageID: "msg_background",
|
||||
id: "call_shell",
|
||||
name: "shell",
|
||||
}),
|
||||
),
|
||||
)
|
||||
fixture.send(
|
||||
durableEvent("session.tool.called", {
|
||||
sessionID: "ses_background",
|
||||
assistantMessageID: "msg_background",
|
||||
id: "call_shell",
|
||||
input: { command: "pwd" },
|
||||
executed: false,
|
||||
}),
|
||||
durableEvent(
|
||||
"session.tool.called",
|
||||
wire({
|
||||
sessionID: "ses_background",
|
||||
assistantMessageID: "msg_background",
|
||||
id: "call_shell",
|
||||
input: { command: "pwd" },
|
||||
executed: false,
|
||||
}),
|
||||
),
|
||||
)
|
||||
fixture.send(
|
||||
durableEvent("session.tool.success", {
|
||||
sessionID: "ses_background",
|
||||
assistantMessageID: "msg_background",
|
||||
id: "call_shell",
|
||||
metadata: { exit: 0 },
|
||||
content: [{ type: "text", text: "/workspace" }],
|
||||
executed: true,
|
||||
}),
|
||||
durableEvent(
|
||||
"session.tool.success",
|
||||
wire({
|
||||
sessionID: "ses_background",
|
||||
assistantMessageID: "msg_background",
|
||||
id: "call_shell",
|
||||
metadata: { exit: 0 },
|
||||
content: [{ type: "text", text: "/workspace" }],
|
||||
executed: true,
|
||||
}),
|
||||
),
|
||||
)
|
||||
fixture.send(durableEvent("session.execution.succeeded", { sessionID: "ses_background" }))
|
||||
fixture.send(durableEvent("session.execution.succeeded", wire({ sessionID: "ses_background" })))
|
||||
await withTimeout(completed.promise, "background child completion was not delivered")
|
||||
|
||||
expect(updates).toEqual([])
|
||||
@@ -370,88 +425,115 @@ describe("acp event behavior", () => {
|
||||
const updates: SessionUpdateParams[] = []
|
||||
const fixture = createSseFixture({
|
||||
onPrompt({ id, send }) {
|
||||
send(durableEvent("session.inbox.delivered", { sessionID: "ses_tools", inboxID: id }))
|
||||
send(durableEvent("session.inbox.delivered", wire({ sessionID: "ses_tools", inboxID: id })))
|
||||
send(
|
||||
durableEvent("session.tool.input.started", {
|
||||
sessionID: "ses_tools",
|
||||
assistantMessageID: "msg_tools",
|
||||
id: "call_ok",
|
||||
name: "shell",
|
||||
}),
|
||||
durableEvent(
|
||||
"session.tool.input.started",
|
||||
wire({
|
||||
sessionID: "ses_tools",
|
||||
assistantMessageID: "msg_tools",
|
||||
id: "call_ok",
|
||||
name: "shell",
|
||||
}),
|
||||
),
|
||||
)
|
||||
send(
|
||||
durableEvent("session.tool.called", {
|
||||
sessionID: "ses_tools",
|
||||
assistantMessageID: "msg_tools",
|
||||
id: "call_ok",
|
||||
input: { command: "printf done", workdir: "sub" },
|
||||
executed: false,
|
||||
}),
|
||||
durableEvent(
|
||||
"session.tool.called",
|
||||
wire({
|
||||
sessionID: "ses_tools",
|
||||
assistantMessageID: "msg_tools",
|
||||
id: "call_ok",
|
||||
input: { command: "printf done", workdir: "sub" },
|
||||
executed: false,
|
||||
}),
|
||||
),
|
||||
)
|
||||
send(
|
||||
ephemeralEvent("session.tool.progress", {
|
||||
sessionID: "ses_tools",
|
||||
assistantMessageID: "msg_tools",
|
||||
id: "call_ok",
|
||||
metadata: { phase: 1 },
|
||||
}),
|
||||
ephemeralEvent(
|
||||
"session.tool.progress",
|
||||
wire({
|
||||
sessionID: "ses_tools",
|
||||
assistantMessageID: "msg_tools",
|
||||
id: "call_ok",
|
||||
metadata: { phase: 1 },
|
||||
}),
|
||||
),
|
||||
)
|
||||
send(
|
||||
durableEvent("session.tool.success", {
|
||||
sessionID: "ses_tools",
|
||||
assistantMessageID: "msg_tools",
|
||||
id: "call_ok",
|
||||
metadata: { exit: 0 },
|
||||
content: [{ type: "text", text: "done" }],
|
||||
executed: true,
|
||||
}),
|
||||
durableEvent(
|
||||
"session.tool.success",
|
||||
wire({
|
||||
sessionID: "ses_tools",
|
||||
assistantMessageID: "msg_tools",
|
||||
id: "call_ok",
|
||||
metadata: { exit: 0 },
|
||||
content: [{ type: "text", text: "done" }],
|
||||
executed: true,
|
||||
}),
|
||||
),
|
||||
)
|
||||
send(
|
||||
durableEvent("session.tool.input.started", {
|
||||
sessionID: "ses_tools",
|
||||
assistantMessageID: "msg_tools",
|
||||
id: "call_fail",
|
||||
name: "read",
|
||||
}),
|
||||
durableEvent(
|
||||
"session.tool.input.started",
|
||||
wire({
|
||||
sessionID: "ses_tools",
|
||||
assistantMessageID: "msg_tools",
|
||||
id: "call_fail",
|
||||
name: "read",
|
||||
}),
|
||||
),
|
||||
)
|
||||
send(
|
||||
durableEvent("session.tool.called", {
|
||||
sessionID: "ses_tools",
|
||||
assistantMessageID: "msg_tools",
|
||||
id: "call_fail",
|
||||
input: { path: "/workspace/missing.ts" },
|
||||
executed: false,
|
||||
}),
|
||||
durableEvent(
|
||||
"session.tool.called",
|
||||
wire({
|
||||
sessionID: "ses_tools",
|
||||
assistantMessageID: "msg_tools",
|
||||
id: "call_fail",
|
||||
input: { path: "/workspace/missing.ts" },
|
||||
executed: false,
|
||||
}),
|
||||
),
|
||||
)
|
||||
send(
|
||||
ephemeralEvent("session.tool.progress", {
|
||||
sessionID: "ses_tools",
|
||||
assistantMessageID: "msg_tools",
|
||||
id: "call_fail",
|
||||
metadata: { bytes: 0 },
|
||||
}),
|
||||
ephemeralEvent(
|
||||
"session.tool.progress",
|
||||
wire({
|
||||
sessionID: "ses_tools",
|
||||
assistantMessageID: "msg_tools",
|
||||
id: "call_fail",
|
||||
metadata: { bytes: 0 },
|
||||
}),
|
||||
),
|
||||
)
|
||||
send(
|
||||
durableEvent("session.tool.failed", {
|
||||
sessionID: "ses_tools",
|
||||
assistantMessageID: "msg_tools",
|
||||
id: "call_fail",
|
||||
error: { type: "tool.error", message: "not found" },
|
||||
metadata: { bytes: 0 },
|
||||
content: [{ type: "text", text: "opening" }],
|
||||
executed: true,
|
||||
}),
|
||||
durableEvent(
|
||||
"session.tool.failed",
|
||||
wire({
|
||||
sessionID: "ses_tools",
|
||||
assistantMessageID: "msg_tools",
|
||||
id: "call_fail",
|
||||
error: { type: "tool.error", message: "not found" },
|
||||
metadata: { bytes: 0 },
|
||||
content: [{ type: "text", text: "opening" }],
|
||||
executed: true,
|
||||
}),
|
||||
),
|
||||
)
|
||||
send(
|
||||
durableEvent("session.step.ended", {
|
||||
sessionID: "ses_tools",
|
||||
assistantMessageID: "msg_tools",
|
||||
finish: "stop",
|
||||
cost: 0,
|
||||
tokens: tokens(),
|
||||
}),
|
||||
durableEvent(
|
||||
"session.step.ended",
|
||||
wire({
|
||||
sessionID: "ses_tools",
|
||||
assistantMessageID: "msg_tools",
|
||||
finish: "stop",
|
||||
cost: 0,
|
||||
tokens: tokens(),
|
||||
}),
|
||||
),
|
||||
)
|
||||
send(durableEvent("session.execution.succeeded", { sessionID: "ses_tools" }))
|
||||
send(durableEvent("session.execution.succeeded", wire({ sessionID: "ses_tools" })))
|
||||
},
|
||||
})
|
||||
|
||||
@@ -597,10 +679,10 @@ describe("acp event behavior", () => {
|
||||
const control: TurnControl = { cancelled: false, admission: new AbortController() }
|
||||
const fixture = createSseFixture({
|
||||
onPrompt({ id, send }) {
|
||||
send(durableEvent("session.inbox.delivered", { sessionID: "ses_cancel", inboxID: id }))
|
||||
send(durableEvent("session.inbox.delivered", wire({ sessionID: "ses_cancel", inboxID: id })))
|
||||
},
|
||||
onInterrupt({ sessionID, send }) {
|
||||
send(durableEvent("session.execution.interrupted", { sessionID, reason: "user" }))
|
||||
send(durableEvent("session.execution.interrupted", wire({ sessionID, reason: "user" })))
|
||||
},
|
||||
})
|
||||
const result = streamTurn({
|
||||
@@ -680,22 +762,25 @@ describe("acp event behavior", () => {
|
||||
test("cancels unsupported session forms so execution can continue", async () => {
|
||||
const fixture = createSseFixture({
|
||||
onPrompt({ id, send }) {
|
||||
send(durableEvent("session.inbox.delivered", { sessionID: "ses_form", inboxID: id }))
|
||||
send(durableEvent("session.inbox.delivered", wire({ sessionID: "ses_form", inboxID: id })))
|
||||
send(
|
||||
ephemeralEvent("form.created", {
|
||||
form: {
|
||||
id: "frm_question",
|
||||
sessionID: "ses_form",
|
||||
title: "Questions",
|
||||
metadata: { kind: "question" },
|
||||
fields: [{ key: "q0", title: "Choice", type: "string" }],
|
||||
},
|
||||
}),
|
||||
ephemeralEvent(
|
||||
"form.created",
|
||||
wire({
|
||||
form: {
|
||||
id: "frm_question",
|
||||
sessionID: "ses_form",
|
||||
title: "Questions",
|
||||
metadata: { kind: "question" },
|
||||
fields: [{ key: "q0", title: "Choice", type: "string" }],
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
onFormCancel({ sessionID, formID, send }) {
|
||||
send(ephemeralEvent("form.cancelled", { sessionID, id: formID }))
|
||||
send(durableEvent("session.execution.succeeded", { sessionID }))
|
||||
send(ephemeralEvent("form.cancelled", wire({ sessionID, id: formID })))
|
||||
send(durableEvent("session.execution.succeeded", wire({ sessionID })))
|
||||
},
|
||||
})
|
||||
|
||||
@@ -763,7 +848,7 @@ function tokens() {
|
||||
}
|
||||
|
||||
function replayFixtureMessages(): SessionMessageInfo[] {
|
||||
return [
|
||||
return wire<SessionMessageInfo[]>([
|
||||
{
|
||||
id: "msg_user",
|
||||
type: "user",
|
||||
@@ -841,11 +926,11 @@ function replayFixtureMessages(): SessionMessageInfo[] {
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
])
|
||||
}
|
||||
|
||||
function replayToolMessage(id: string) {
|
||||
return {
|
||||
return wire<SessionMessageInfo>({
|
||||
id: `msg_${id}`,
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
@@ -865,5 +950,5 @@ function replayToolMessage(id: string) {
|
||||
},
|
||||
},
|
||||
],
|
||||
} satisfies SessionMessageInfo
|
||||
})
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type TokenUsageInfo,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { ACPService } from "../../src/acp/service"
|
||||
import { wire, type Wire } from "../fixture/wire"
|
||||
|
||||
export type FixtureRequest = {
|
||||
readonly method: string
|
||||
@@ -37,7 +38,7 @@ type FixtureOptions = {
|
||||
readonly skills?: readonly SkillInfo[]
|
||||
}
|
||||
|
||||
export const testModel = {
|
||||
export const testModel = wire<ModelInfo>({
|
||||
id: "test-model",
|
||||
modelID: "test-model",
|
||||
providerID: "test",
|
||||
@@ -49,9 +50,9 @@ export const testModel = {
|
||||
status: "active",
|
||||
enabled: true,
|
||||
limit: { context: 100_000, output: 10_000 },
|
||||
} satisfies ModelInfo
|
||||
})
|
||||
|
||||
export const secondModel = {
|
||||
export const secondModel = wire<ModelInfo>({
|
||||
id: "second-model",
|
||||
modelID: "second-model",
|
||||
providerID: "test",
|
||||
@@ -63,18 +64,18 @@ export const secondModel = {
|
||||
status: "active",
|
||||
enabled: true,
|
||||
limit: { context: 200_000, output: 20_000 },
|
||||
} satisfies ModelInfo
|
||||
})
|
||||
|
||||
export const buildAgent = {
|
||||
export const buildAgent = wire<AgentInfo>({
|
||||
id: "build",
|
||||
name: "Build",
|
||||
request: { settings: {}, headers: {}, body: {} },
|
||||
mode: "primary",
|
||||
hidden: false,
|
||||
permissions: [],
|
||||
} satisfies AgentInfo
|
||||
})
|
||||
|
||||
export const planAgent = {
|
||||
export const planAgent = wire<AgentInfo>({
|
||||
id: "plan",
|
||||
name: "Plan",
|
||||
description: "Plan first",
|
||||
@@ -82,36 +83,36 @@ export const planAgent = {
|
||||
mode: "primary",
|
||||
hidden: false,
|
||||
permissions: [],
|
||||
} satisfies AgentInfo
|
||||
})
|
||||
|
||||
export const reviewCommand = {
|
||||
export const reviewCommand = wire<CommandInfo>({
|
||||
name: "review",
|
||||
description: "Review changes",
|
||||
template: "",
|
||||
} satisfies CommandInfo
|
||||
})
|
||||
|
||||
export const verifySkill = {
|
||||
export const verifySkill = wire<SkillInfo>({
|
||||
id: "verify",
|
||||
name: "verify",
|
||||
description: "Verify work",
|
||||
slash: true,
|
||||
location: "/skills/verify.md",
|
||||
content: "verify",
|
||||
} satisfies SkillInfo
|
||||
})
|
||||
|
||||
export function makeSession(
|
||||
id: string,
|
||||
input: {
|
||||
readonly cwd?: string
|
||||
readonly agent?: string
|
||||
readonly model?: ModelRef
|
||||
readonly model?: Wire<ModelRef>
|
||||
readonly cost?: number
|
||||
readonly tokens?: TokenUsageInfo
|
||||
readonly time?: SessionInfo["time"]
|
||||
readonly title?: string
|
||||
} = {},
|
||||
): SessionInfo {
|
||||
return {
|
||||
return wire<SessionInfo>({
|
||||
id,
|
||||
projectID: "global",
|
||||
agent: input.agent ?? "build",
|
||||
@@ -121,7 +122,7 @@ export function makeSession(
|
||||
time: input.time ?? { created: 0, updated: 0 },
|
||||
title: input.title ?? `Session ${id}`,
|
||||
location: { directory: input.cwd ?? "/workspace" },
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function makeACPFixture(options: FixtureOptions = {}) {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionConfigOption } from "@agentclientprotocol/sdk"
|
||||
import type { ModelInfo } from "@opencode-ai/client/promise"
|
||||
import { wire } from "../fixture/wire"
|
||||
import { makeACPFixture, makeSession, secondModel } from "./service-fixture"
|
||||
|
||||
describe("acp service lifecycle", () => {
|
||||
test("does not persist the first catalog variant when no explicit default exists", async () => {
|
||||
const model = { ...secondModel, variants: [{ id: "none" }, { id: "high" }] }
|
||||
const model = wire<ModelInfo>({ ...secondModel, variants: [{ id: "none" }, { id: "high" }] })
|
||||
await using fixture = makeACPFixture({
|
||||
models: [model],
|
||||
defaultModel: model,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { AgentSideConnection } from "@agentclientprotocol/sdk"
|
||||
import { OpenCode } from "@opencode-ai/client/promise"
|
||||
import { OpenCode, type AgentInfo, type ModelInfo, type SessionInfo, type SkillInfo } from "@opencode-ai/client/promise"
|
||||
import { ACPService } from "../../src/acp/service"
|
||||
import { ChildSessionUpdatesCapability } from "../../src/acp/event"
|
||||
import { wire } from "../fixture/wire"
|
||||
|
||||
describe("acp service", () => {
|
||||
test("creates a v2 session, registers mcp, and publishes commands", async () => {
|
||||
@@ -72,7 +73,7 @@ describe("acp service", () => {
|
||||
})
|
||||
})
|
||||
|
||||
const model = {
|
||||
const model = wire<ModelInfo>({
|
||||
id: "test-model",
|
||||
modelID: "test-model",
|
||||
providerID: "test",
|
||||
@@ -81,30 +82,30 @@ const model = {
|
||||
variants: [{ id: "default" }, { id: "high" }],
|
||||
time: { released: 0 },
|
||||
cost: [],
|
||||
status: "active" as const,
|
||||
status: "active",
|
||||
enabled: true,
|
||||
limit: { context: 100_000, output: 10_000 },
|
||||
}
|
||||
})
|
||||
|
||||
const agent = {
|
||||
const agent = wire<AgentInfo>({
|
||||
id: "build",
|
||||
name: "Build",
|
||||
request: { settings: {}, headers: {}, body: {} },
|
||||
mode: "primary" as const,
|
||||
mode: "primary",
|
||||
hidden: false,
|
||||
permissions: [],
|
||||
}
|
||||
})
|
||||
|
||||
const skill = {
|
||||
const skill = wire<SkillInfo>({
|
||||
id: "verify",
|
||||
name: "verify",
|
||||
description: "Verify work",
|
||||
slash: true,
|
||||
location: "/skills/verify.md",
|
||||
content: "verify",
|
||||
}
|
||||
})
|
||||
|
||||
const session = {
|
||||
const session = wire<SessionInfo>({
|
||||
id: "ses_acp",
|
||||
projectID: "global",
|
||||
agent: "build",
|
||||
@@ -114,4 +115,4 @@ const session = {
|
||||
time: { created: 0, updated: 0 },
|
||||
title: "New session",
|
||||
location: { directory: "/workspace" },
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { OpenCode, type OpenCodeEvent, type SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import { wire, type Wire } from "../fixture/wire"
|
||||
|
||||
type DurableEvent = Extract<OpenCodeEvent, { durable: unknown }>
|
||||
type EphemeralEvent = Exclude<OpenCodeEvent, DurableEvent>
|
||||
@@ -39,7 +40,7 @@ const ids = { next: 0 }
|
||||
|
||||
export function durableEvent<Type extends DurableEvent["type"]>(
|
||||
type: Type,
|
||||
data: Extract<DurableEvent, { type: Type }>["data"],
|
||||
data: Extract<DurableEvent, { type: Type }>["data"] | Wire<Extract<DurableEvent, { type: Type }>["data"]>,
|
||||
) {
|
||||
ids.next++
|
||||
return {
|
||||
@@ -53,7 +54,7 @@ export function durableEvent<Type extends DurableEvent["type"]>(
|
||||
|
||||
export function ephemeralEvent<Type extends EphemeralEvent["type"]>(
|
||||
type: Type,
|
||||
data: Extract<EphemeralEvent, { type: Type }>["data"],
|
||||
data: Extract<EphemeralEvent, { type: Type }>["data"] | Wire<Extract<EphemeralEvent, { type: Type }>["data"]>,
|
||||
) {
|
||||
ids.next++
|
||||
return { id: `evt_${ids.next}`, created: ids.next, type, data }
|
||||
@@ -194,7 +195,7 @@ function stringField(value: unknown, key: string) {
|
||||
}
|
||||
|
||||
function assistantMessage(id: string) {
|
||||
return {
|
||||
return wire<SessionMessageInfo>({
|
||||
id,
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
@@ -203,5 +204,5 @@ function assistantMessage(id: string) {
|
||||
finish: "stop",
|
||||
tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1, completed: 2 },
|
||||
} satisfies SessionMessageInfo
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Brand } from "effect"
|
||||
|
||||
export type Wire<T> =
|
||||
T extends Brand.Brand<string>
|
||||
? Brand.Brand.Unbranded<T>
|
||||
: T extends ReadonlyArray<infer Item>
|
||||
? Wire<Item>[]
|
||||
: T extends object
|
||||
? { [Key in keyof T]: Wire<T[Key]> }
|
||||
: T
|
||||
|
||||
export function wire<T>(value: Wire<T>): T {
|
||||
return value as T
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { ClientError, OpenCode } from "@opencode-ai/client/promise"
|
||||
import { ClientError, OpenCode, type ModelRef } from "@opencode-ai/client/promise"
|
||||
import { OPENCODE_VERSION } from "../src/version"
|
||||
import path from "node:path"
|
||||
import { createMiniConnection, mergeInput as mergeInteractiveInput, resolveMiniTarget } from "../src/mini"
|
||||
import { mergeInput as mergeNonInteractiveInput, parseRunModel } from "../src/run/run"
|
||||
import { parseSessionTargetModel } from "../src/session-target"
|
||||
import { wire } from "./fixture/wire"
|
||||
|
||||
async function cli(args: string[]) {
|
||||
const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], {
|
||||
@@ -106,11 +107,13 @@ describe("mini command", () => {
|
||||
expect(JSON.stringify(parseRunModel("openrouter/openai/gpt-5#high"))).toBe(
|
||||
JSON.stringify({ model: { providerID: "openrouter", modelID: "openai/gpt-5" }, variant: "high" }),
|
||||
)
|
||||
expect(parseSessionTargetModel("openrouter/openai/gpt-5#high")).toEqual({
|
||||
providerID: "openrouter",
|
||||
id: "openai/gpt-5",
|
||||
variant: "high",
|
||||
})
|
||||
expect(parseSessionTargetModel("openrouter/openai/gpt-5#high")).toEqual(
|
||||
wire<ModelRef>({
|
||||
providerID: "openrouter",
|
||||
id: "openai/gpt-5",
|
||||
variant: "high",
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("is registered in the preview CLI", async () => {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { EOL } from "node:os"
|
||||
import { format } from "../src/commands/handlers/plugin/list"
|
||||
import { wire } from "./fixture/wire"
|
||||
|
||||
test("formats server and TUI plugins in sections without builtins", () => {
|
||||
expect(
|
||||
format(
|
||||
[
|
||||
wire<Parameters<typeof format>[0]>([
|
||||
{ id: "opencode.agent", source: { type: "builtin" }, status: "active", tui: false },
|
||||
{
|
||||
id: "acme.dual",
|
||||
@@ -19,7 +20,7 @@ test("formats server and TUI plugins in sections without builtins", () => {
|
||||
error: "broken",
|
||||
tui: false,
|
||||
},
|
||||
],
|
||||
]),
|
||||
[
|
||||
{ target: "tui-only", source: "configured" },
|
||||
{ target: "/tmp/local.ts", source: "discovered" },
|
||||
@@ -41,6 +42,12 @@ test("formats server and TUI plugins in sections without builtins", () => {
|
||||
|
||||
test("includes builtins when requested", () => {
|
||||
expect(
|
||||
format([{ id: "opencode.agent", source: { type: "builtin" }, status: "active", tui: false }], [], true),
|
||||
format(
|
||||
wire<Parameters<typeof format>[0]>([
|
||||
{ id: "opencode.agent", source: { type: "builtin" }, status: "active", tui: false },
|
||||
]),
|
||||
[],
|
||||
true,
|
||||
),
|
||||
).toBe(["Server", "opencode.agent (active)"].join(EOL))
|
||||
})
|
||||
|
||||
@@ -2,62 +2,70 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
|
||||
import {
|
||||
OpenCode,
|
||||
type EventSubscribeOutput,
|
||||
type LocationRef,
|
||||
type SessionMessageAssistantTool,
|
||||
type SessionMessageInfo,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { runNonInteractivePrompt } from "../../src/run/noninteractive"
|
||||
import { type Wire, wire } from "../fixture/wire"
|
||||
|
||||
type V2Event = EventSubscribeOutput
|
||||
type FormInfo = Extract<V2Event, { type: "form.created" }>["data"]["form"]
|
||||
const location = { directory: "/work tree", workspaceID: "wrk_1" }
|
||||
const location = wire<LocationRef>({ directory: "/work tree", workspaceID: "wrk_1" })
|
||||
|
||||
function ok<T>(data: T) {
|
||||
return Promise.resolve(data)
|
||||
}
|
||||
|
||||
function form(id: string, sessionID: string): FormInfo {
|
||||
return {
|
||||
return wire<FormInfo>({
|
||||
id,
|
||||
sessionID,
|
||||
title: "Input requested",
|
||||
fields: [{ key: "authorization", type: "external", url: "https://example.com/form" }],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function formCreated(info: FormInfo, eventLocation = location): V2Event {
|
||||
return { id: `evt_${info.id}`, created: 0, type: "form.created", location: eventLocation, data: { form: info } }
|
||||
return wire<V2Event>({
|
||||
id: `evt_${info.id}`,
|
||||
created: 0,
|
||||
type: "form.created",
|
||||
location: eventLocation,
|
||||
data: { form: info },
|
||||
})
|
||||
}
|
||||
|
||||
function prompted(inboxID: string): V2Event {
|
||||
return {
|
||||
return wire<V2Event>({
|
||||
id: "evt_prompted",
|
||||
created: 0,
|
||||
type: "session.inbox.delivered",
|
||||
durable: { aggregateID: "ses_1", seq: 0, version: 1 },
|
||||
data: { sessionID: "ses_1", inboxID },
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function settled(outcome: "success" | "interrupted" = "success"): V2Event {
|
||||
if (outcome === "interrupted")
|
||||
return {
|
||||
return wire<V2Event>({
|
||||
id: "evt_interrupted",
|
||||
created: 0,
|
||||
type: "session.execution.interrupted",
|
||||
durable: { aggregateID: "ses_1", seq: 1, version: 1 },
|
||||
data: { sessionID: "ses_1", reason: "user" },
|
||||
}
|
||||
return {
|
||||
})
|
||||
return wire<V2Event>({
|
||||
id: "evt_succeeded",
|
||||
created: 0,
|
||||
type: "session.execution.succeeded",
|
||||
durable: { aggregateID: "ses_1", seq: 1, version: 1 },
|
||||
data: { sessionID: "ses_1" },
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function stepStarted(): V2Event {
|
||||
return {
|
||||
return wire<V2Event>({
|
||||
id: "evt_step_started",
|
||||
created: 1,
|
||||
type: "session.step.started",
|
||||
@@ -68,11 +76,11 @@ function stepStarted(): V2Event {
|
||||
agent: "build",
|
||||
model: { providerID: "test", id: "test-model" },
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function stepFailed(message: string): V2Event {
|
||||
return {
|
||||
return wire<V2Event>({
|
||||
id: "evt_step_failed",
|
||||
created: 2,
|
||||
type: "session.step.failed",
|
||||
@@ -82,11 +90,11 @@ function stepFailed(message: string): V2Event {
|
||||
assistantMessageID: "msg_assistant",
|
||||
error: { type: "provider.transport", message },
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function executionFailed(message: string): V2Event {
|
||||
return {
|
||||
return wire<V2Event>({
|
||||
id: "evt_execution_failed",
|
||||
created: 3,
|
||||
type: "session.execution.failed",
|
||||
@@ -95,11 +103,11 @@ function executionFailed(message: string): V2Event {
|
||||
sessionID: "ses_1",
|
||||
error: { type: "provider.transport", message },
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function failedTool(inboxID: string): V2Event[] {
|
||||
return [
|
||||
return wire<V2Event[]>([
|
||||
prompted(inboxID),
|
||||
{
|
||||
id: "evt_failed_tool_input",
|
||||
@@ -153,12 +161,12 @@ function failedTool(inboxID: string): V2Event[] {
|
||||
},
|
||||
},
|
||||
settled(),
|
||||
]
|
||||
])
|
||||
}
|
||||
|
||||
function successfulGrep(inboxID: string): V2Event[] {
|
||||
const text = "Found 2 matches\n/src/a.ts:\n Line 1: needle\n/src/b.ts:\n Line 2: needle"
|
||||
return [
|
||||
return wire<V2Event[]>([
|
||||
prompted(inboxID),
|
||||
{
|
||||
id: "evt_grep_input",
|
||||
@@ -200,7 +208,7 @@ function successfulGrep(inboxID: string): V2Event[] {
|
||||
},
|
||||
},
|
||||
settled(),
|
||||
]
|
||||
])
|
||||
}
|
||||
|
||||
// Runs one non-interactive prompt against a mocked SDK. `turn` produces the
|
||||
@@ -214,12 +222,12 @@ async function run(input: {
|
||||
cancel?: (input: { sessionID: string; formID: string }) => Promise<void>
|
||||
renderTool?: (part: SessionMessageAssistantTool) => Promise<void>
|
||||
renderToolError?: (part: SessionMessageAssistantTool) => Promise<void>
|
||||
messages?: (inboxID: string) => SessionMessageInfo[]
|
||||
messages?: (inboxID: string) => Wire<SessionMessageInfo[]>
|
||||
wait?: () => Promise<void>
|
||||
terminalDelay?: number
|
||||
}) {
|
||||
const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
|
||||
const values: V2Event[] = [{ id: "evt_connected", type: "server.connected", data: {} }]
|
||||
const values: V2Event[] = [wire<V2Event>({ id: "evt_connected", type: "server.connected", data: {} })]
|
||||
let wake: (() => void) | undefined
|
||||
const wait = Promise.withResolvers<void>()
|
||||
const stream = (async function* (): AsyncGenerator<V2Event, void, unknown> {
|
||||
@@ -254,10 +262,12 @@ async function run(input: {
|
||||
let promptID = "msg_prompt"
|
||||
spyOn(sdk.session, "wait").mockImplementation(() => input.wait?.() ?? wait.promise)
|
||||
spyOn(sdk.message, "list").mockImplementation(() =>
|
||||
ok({
|
||||
data: input.messages?.(promptID) ?? [{ id: promptID, type: "user", text: "hello", time: { created: 1 } }],
|
||||
cursor: {},
|
||||
}),
|
||||
ok(
|
||||
wire<Awaited<ReturnType<typeof sdk.message.list>>>({
|
||||
data: input.messages?.(promptID) ?? [{ id: promptID, type: "user", text: "hello", time: { created: 1 } }],
|
||||
cursor: {},
|
||||
}),
|
||||
),
|
||||
)
|
||||
spyOn(sdk.session, "prompt").mockImplementation((request) => {
|
||||
const messageID = request.id ?? "msg_prompt"
|
||||
@@ -265,7 +275,16 @@ async function run(input: {
|
||||
values.push(...input.turn(messageID))
|
||||
wake?.()
|
||||
wake = undefined
|
||||
return ok({ id: messageID, sessionID: "ses_1", timeCreated: 1 }) as never
|
||||
return ok(
|
||||
wire<Awaited<ReturnType<typeof sdk.session.prompt>>>({
|
||||
id: messageID,
|
||||
sessionID: "ses_1",
|
||||
timeCreated: 1,
|
||||
type: "user",
|
||||
payload: { text: "hello" },
|
||||
delivery: "steer",
|
||||
}),
|
||||
)
|
||||
})
|
||||
await runNonInteractivePrompt({
|
||||
client: sdk,
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
|
||||
import { OpenCode, type LocationGetOutput, type ModelRef, type SessionInfo } from "@opencode-ai/client/promise"
|
||||
import { resolveSessionTarget, SessionTargetMutationError } from "../src/session-target"
|
||||
import { type Wire, wire } from "./fixture/wire"
|
||||
|
||||
function location(directory: string, workspaceID?: string): LocationGetOutput {
|
||||
return { directory, workspaceID, project: { id: "project", directory, canonical: directory } }
|
||||
return wire<LocationGetOutput>({
|
||||
directory,
|
||||
workspaceID,
|
||||
project: { id: "project", directory, canonical: directory },
|
||||
})
|
||||
}
|
||||
|
||||
function session(id: string, directory: string, workspaceID?: string, model?: ModelRef): SessionInfo {
|
||||
return {
|
||||
function session(id: string, directory: string, workspaceID?: string, model?: Wire<ModelRef>): SessionInfo {
|
||||
return wire<SessionInfo>({
|
||||
id,
|
||||
projectID: "project",
|
||||
title: id,
|
||||
@@ -16,7 +21,7 @@ function session(id: string, directory: string, workspaceID?: string, model?: Mo
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1, updated: 1 },
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const prepare = async (input: { model: ModelRef | undefined; agent: string | undefined }) => ({
|
||||
@@ -52,7 +57,7 @@ describe("session target resolver", () => {
|
||||
|
||||
const target = await resolveSessionTarget({ client, location: { directory: "/project" }, continue: true, prepare })
|
||||
expect(list).toHaveBeenCalledTimes(2)
|
||||
expect(target.session.id).toBe("ses_implicit")
|
||||
expect(String(target.session.id)).toBe("ses_implicit")
|
||||
})
|
||||
|
||||
test("attaches the terminal environment to the resolved local Session", async () => {
|
||||
@@ -90,7 +95,7 @@ describe("session target resolver", () => {
|
||||
agent: "requested",
|
||||
prepare: async (input) => {
|
||||
order.push("prepare")
|
||||
expect(input.location.workspaceID).toBe("work_1")
|
||||
expect(String(input.location.workspaceID)).toBe("work_1")
|
||||
return { model: input.model, agent: "prepared" }
|
||||
},
|
||||
})
|
||||
@@ -101,7 +106,9 @@ describe("session target resolver", () => {
|
||||
test("uses the agent resolved by the server for a fresh Session", async () => {
|
||||
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
|
||||
spyOn(client.location, "get").mockResolvedValue(location("/project"))
|
||||
spyOn(client.session, "create").mockResolvedValue({ ...session("ses_fresh", "/project"), agent: "review" })
|
||||
spyOn(client.session, "create").mockResolvedValue(
|
||||
wire<SessionInfo>({ ...session("ses_fresh", "/project"), agent: "review" }),
|
||||
)
|
||||
|
||||
const target = await resolveSessionTarget({ client, prepare })
|
||||
expect(target.agent).toBe("review")
|
||||
|
||||
@@ -33,6 +33,7 @@ import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { SessionInbox } from "@opencode-ai/schema/session-inbox"
|
||||
import { Shell } from "@opencode-ai/schema/shell"
|
||||
import { Skill } from "@opencode-ai/schema/skill"
|
||||
import { Snapshot } from "@opencode-ai/schema/snapshot"
|
||||
import { Vcs } from "@opencode-ai/schema/vcs"
|
||||
import { WebSearch } from "@opencode-ai/schema/websearch"
|
||||
import { Workspace } from "@opencode-ai/schema/workspace"
|
||||
@@ -83,12 +84,28 @@ const effectTypeReferences = [
|
||||
typeReference("PositiveInt", "@opencode-ai/schema/schema", PositiveInt),
|
||||
typeReference("RelativePath", "@opencode-ai/schema/schema", RelativePath),
|
||||
]
|
||||
const promiseTypeReferences = [
|
||||
...effectTypeReferences.filter(
|
||||
(reference) =>
|
||||
reference.name.endsWith("ID") && !reference.name.startsWith("Project.") && !reference.name.startsWith("Pty."),
|
||||
),
|
||||
...namespaceTypes("ProjectSchema", "@opencode-ai/schema/project", Project, "Project").filter((reference) =>
|
||||
reference.name.endsWith("ID"),
|
||||
),
|
||||
...namespaceTypes("PtySchema", "@opencode-ai/schema/pty", Pty, "Pty").filter((reference) =>
|
||||
reference.name.endsWith("ID"),
|
||||
),
|
||||
...namespaceTypes("Snapshot", "@opencode-ai/schema/snapshot", Snapshot).filter((reference) =>
|
||||
reference.name.endsWith("ID"),
|
||||
),
|
||||
]
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.all(
|
||||
[
|
||||
write(
|
||||
emitPromise(promiseContract, {
|
||||
typeReferences: promiseTypeReferences,
|
||||
mutableOutputs: true,
|
||||
}),
|
||||
fileURLToPath(new URL("../src/promise/generated", import.meta.url)),
|
||||
@@ -118,16 +135,17 @@ await Effect.runPromise(
|
||||
).pipe(Effect.provide(NodeFileSystem.layer)),
|
||||
)
|
||||
|
||||
function namespaceTypes(namespace: string, module: string, values: object) {
|
||||
function namespaceTypes(namespace: string, module: string, values: object, importedName = namespace) {
|
||||
return Object.entries(values).flatMap(([name, schema]) =>
|
||||
Schema.isSchema(schema) ? [typeReference(`${namespace}.${name}`, module, schema)] : [],
|
||||
Schema.isSchema(schema) ? [typeReference(`${namespace}.${name}`, module, schema, importedName)] : [],
|
||||
)
|
||||
}
|
||||
|
||||
function typeReference(name: string, module: string, schema: Schema.Top) {
|
||||
function typeReference(name: string, module: string, schema: Schema.Top, importedName = name.split(".")[0]) {
|
||||
const namespace = name.split(".")[0]
|
||||
return {
|
||||
schema,
|
||||
name,
|
||||
import: `import type { ${name.split(".")[0]} } from ${JSON.stringify(module)}`,
|
||||
import: `import type { ${importedName === namespace ? importedName : `${importedName} as ${namespace}`} } from ${JSON.stringify(module)}`,
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -34,6 +34,8 @@ import type {
|
||||
OpenCodeClient,
|
||||
WebSearchProvider,
|
||||
} from "../promise"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { ProjectID } from "@opencode-ai/schema/project-id"
|
||||
import { Worktree } from "@opencode-ai/schema/worktree"
|
||||
import { SessionID } from "@opencode-ai/schema/session-id"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
@@ -59,8 +61,6 @@ export type CreateDataInput = {
|
||||
}
|
||||
}
|
||||
|
||||
const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_")
|
||||
|
||||
// Global MCP elicitations temporarily use "global" instead of a real session ID, so the
|
||||
// server cannot recover their Location when settling them. Preserve the event Location
|
||||
// until MCP elicitations carry session ownership.
|
||||
@@ -490,7 +490,7 @@ export function createData(config: CreateDataInput) {
|
||||
setStore("session", "info", event.data.sessionID, "agent", event.data.agent)
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.append(draft, index, {
|
||||
id: messageIDFromEvent(event.id),
|
||||
id: SessionMessage.ID.fromEvent(event.id),
|
||||
type: "agent-switched",
|
||||
agent: event.data.agent,
|
||||
previous,
|
||||
@@ -505,14 +505,14 @@ export function createData(config: CreateDataInput) {
|
||||
if (!store.session.message[event.data.sessionID]) return
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.append(draft, index, {
|
||||
id: messageIDFromEvent(event.id),
|
||||
id: SessionMessage.ID.fromEvent(event.id),
|
||||
type: "model-switched",
|
||||
model: event.data.model,
|
||||
time: { created: event.created },
|
||||
})
|
||||
})
|
||||
void api()
|
||||
.session.message({ sessionID: event.data.sessionID, messageID: messageIDFromEvent(event.id) })
|
||||
.session.message({ sessionID: event.data.sessionID, messageID: SessionMessage.ID.fromEvent(event.id) })
|
||||
.then((item) => {
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const position = index.get(item.id)
|
||||
@@ -542,7 +542,7 @@ export function createData(config: CreateDataInput) {
|
||||
setStore("session", "info", event.data.sessionID, "subpath", event.data.subpath)
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.append(draft, index, {
|
||||
id: messageIDFromEvent(event.id),
|
||||
id: SessionMessage.ID.fromEvent(event.id),
|
||||
type: "location-switched",
|
||||
location: event.data.location,
|
||||
projectID: event.data.projectID,
|
||||
@@ -602,7 +602,7 @@ export function createData(config: CreateDataInput) {
|
||||
if (updateText === undefined) return
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.append(draft, index, {
|
||||
id: messageIDFromEvent(event.id),
|
||||
id: SessionMessage.ID.fromEvent(event.id),
|
||||
type: "system",
|
||||
text: updateText,
|
||||
description: `Instructions updated: ${Object.keys(event.data.delta).join(", ")}`,
|
||||
@@ -614,7 +614,7 @@ export function createData(config: CreateDataInput) {
|
||||
case "session.synthetic":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.append(draft, index, {
|
||||
id: messageIDFromEvent(event.id),
|
||||
id: SessionMessage.ID.fromEvent(event.id),
|
||||
type: "synthetic",
|
||||
text: event.data.text,
|
||||
description: event.data.description,
|
||||
@@ -626,7 +626,7 @@ export function createData(config: CreateDataInput) {
|
||||
case "session.shell.started":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.append(draft, index, {
|
||||
id: messageIDFromEvent(event.id),
|
||||
id: SessionMessage.ID.fromEvent(event.id),
|
||||
type: "shell",
|
||||
shellID: event.data.shell.id,
|
||||
command: event.data.shell.command,
|
||||
@@ -864,7 +864,7 @@ export function createData(config: CreateDataInput) {
|
||||
if (event.data.inputID) removePending(event.data.sessionID, event.data.inputID)
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.append(draft, index, {
|
||||
id: event.data.inputID ?? messageIDFromEvent(event.id),
|
||||
id: event.data.inputID ?? SessionMessage.ID.fromEvent(event.id),
|
||||
type: "compaction",
|
||||
status: "running",
|
||||
reason: event.data.reason,
|
||||
@@ -927,7 +927,7 @@ export function createData(config: CreateDataInput) {
|
||||
return
|
||||
}
|
||||
message.append(draft, index, {
|
||||
id: messageIDFromEvent(event.id),
|
||||
id: SessionMessage.ID.fromEvent(event.id),
|
||||
type: "compaction",
|
||||
status: "completed",
|
||||
reason: event.data.reason,
|
||||
@@ -943,7 +943,7 @@ export function createData(config: CreateDataInput) {
|
||||
const position = draft.findLastIndex((item) => item.type === "compaction" && item.status === "running")
|
||||
const current = draft[position]
|
||||
const failed: Extract<SessionMessageInfo, { type: "compaction"; status: "failed" }> = {
|
||||
id: current?.id ?? event.data.inputID ?? messageIDFromEvent(event.id),
|
||||
id: current?.id ?? event.data.inputID ?? SessionMessage.ID.fromEvent(event.id),
|
||||
type: "compaction",
|
||||
status: "failed",
|
||||
reason: event.data.reason ?? "manual",
|
||||
@@ -1164,9 +1164,9 @@ export function createData(config: CreateDataInput) {
|
||||
const now = Date.now()
|
||||
sessionOutbox.add(id)
|
||||
result.session.remember({
|
||||
id,
|
||||
projectID: projectID ?? store.location[locationKey(location)]?.info?.project.id ?? "",
|
||||
agent: payload.agent,
|
||||
id: SessionID.make(id),
|
||||
projectID: ProjectID.make(projectID ?? store.location[locationKey(location)]?.info?.project.id ?? ""),
|
||||
agent: payload.agent === undefined ? undefined : Agent.ID.make(payload.agent),
|
||||
model: payload.model,
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
@@ -1214,8 +1214,8 @@ export function createData(config: CreateDataInput) {
|
||||
if (fresh) {
|
||||
outbox.add(id)
|
||||
admitLocal({
|
||||
id,
|
||||
sessionID: request.sessionID,
|
||||
id: SessionMessage.ID.make(id),
|
||||
sessionID: SessionID.make(request.sessionID),
|
||||
timeCreated: Date.now(),
|
||||
type: "user",
|
||||
delivery: request.delivery ?? "steer",
|
||||
|
||||
@@ -24,6 +24,23 @@ test("generated Effect API names canonical and composed outputs", async () => {
|
||||
expect(source).not.toContain("HttpApiClient.ForApi")
|
||||
})
|
||||
|
||||
test("generated Promise outputs preserve canonical ID brands", async () => {
|
||||
const source = await Bun.file(new URL("../src/promise/generated/types.ts", import.meta.url)).text()
|
||||
|
||||
expect(source).toContain('import type { Project as ProjectSchema } from "@opencode-ai/schema/project"')
|
||||
expect(source).toContain('import type { Pty as PtySchema } from "@opencode-ai/schema/pty"')
|
||||
expect(source).toContain("export type SessionInfo = {")
|
||||
expect(source).toContain("id: Session.ID")
|
||||
expect(source).toContain("export type Project = {")
|
||||
expect(source).toContain("id: ProjectSchema.ID")
|
||||
expect(source).toContain("export type Pty = {")
|
||||
expect(source).toContain("id: PtySchema.ID")
|
||||
expect(source).toContain(
|
||||
'export type SessionGetInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }',
|
||||
)
|
||||
expect(source).not.toContain("Brand.Brand")
|
||||
})
|
||||
|
||||
test("shared DTO schemas construct and decode plain objects", () => {
|
||||
const made = Prompt.make({ text: "hello" })
|
||||
const decoded = Schema.decodeUnknownSync(Prompt)({ text: "hello" })
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export * as ConfigCommandPlugin from "./command.js"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Info, type Entry } from "@opencode-ai/schema/config"
|
||||
import { ConfigCommand } from "@opencode-ai/schema/config/command"
|
||||
import path from "path"
|
||||
@@ -54,7 +55,7 @@ export const Plugin = define({
|
||||
draft.update(name, (item) => {
|
||||
item.template = command.template
|
||||
if (command.description !== undefined) item.description = command.description
|
||||
if (command.agent !== undefined) item.agent = command.agent
|
||||
if (command.agent !== undefined) item.agent = Agent.ID.make(command.agent)
|
||||
if (command.model !== undefined)
|
||||
item.model = {
|
||||
id: command.model.model,
|
||||
|
||||
@@ -9,6 +9,12 @@ import type {
|
||||
SessionMessageInfo,
|
||||
SessionMessageUser,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Project } from "@opencode-ai/schema/project"
|
||||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import type { SessionV1 } from "@opencode-ai/schema/session-v1"
|
||||
import type { Share } from "./share"
|
||||
|
||||
@@ -64,20 +70,19 @@ async function mapFromLegacySession(blob: {
|
||||
function currentSession(session: typeof SessionV1.SessionInfo.Type, messages: LegacyMessage[]): SessionInfo {
|
||||
const latestUser = messages.findLast((message): message is typeof SessionV1.User.Type => message.role === "user")
|
||||
const model =
|
||||
session.model ??
|
||||
(latestUser
|
||||
? {
|
||||
id: latestUser.model.modelID,
|
||||
providerID: latestUser.model.providerID,
|
||||
...(latestUser.model.variant ? { variant: latestUser.model.variant } : {}),
|
||||
}
|
||||
: undefined)
|
||||
(session.model && currentModel(session.model)) ??
|
||||
(latestUser &&
|
||||
currentModel({
|
||||
id: latestUser.model.modelID,
|
||||
providerID: latestUser.model.providerID,
|
||||
variant: latestUser.model.variant,
|
||||
}))
|
||||
const agent = session.agent ?? latestUser?.agent
|
||||
return {
|
||||
id: session.id,
|
||||
projectID: session.projectID,
|
||||
...(session.parentID ? { parentID: session.parentID } : {}),
|
||||
...(agent ? { agent } : {}),
|
||||
id: Session.ID.make(session.id),
|
||||
projectID: Project.ID.make(session.projectID),
|
||||
...(session.parentID ? { parentID: Session.ID.make(session.parentID) } : {}),
|
||||
...(agent ? { agent: Agent.ID.make(agent) } : {}),
|
||||
...(model ? { model } : {}),
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
@@ -103,7 +108,7 @@ function currentMessages(messages: LegacyMessage[], parts: LegacyPart[]): Sessio
|
||||
if (compaction?.type === "compaction")
|
||||
return [
|
||||
{
|
||||
id: message.id,
|
||||
id: SessionMessage.ID.make(message.id),
|
||||
type: "compaction",
|
||||
status: "completed",
|
||||
reason: compaction.auto ? "auto" : "manual",
|
||||
@@ -143,18 +148,18 @@ function currentUser(message: typeof SessionV1.User.Type, parts: LegacyPart[]):
|
||||
if (!text && !files.length && !agents.length) return []
|
||||
return [
|
||||
{
|
||||
id: message.id,
|
||||
id: SessionMessage.ID.make(message.id),
|
||||
type: "user",
|
||||
text,
|
||||
...(files.length ? { files } : {}),
|
||||
...(agents.length ? { agents } : {}),
|
||||
metadata: {
|
||||
agent: message.agent,
|
||||
model: {
|
||||
agent: Agent.ID.make(message.agent),
|
||||
model: currentModel({
|
||||
id: message.model.modelID,
|
||||
providerID: message.model.providerID,
|
||||
...(message.model.variant ? { variant: message.model.variant } : {}),
|
||||
},
|
||||
variant: message.model.variant,
|
||||
}),
|
||||
},
|
||||
time: { created: message.time.created },
|
||||
},
|
||||
@@ -163,14 +168,14 @@ function currentUser(message: typeof SessionV1.User.Type, parts: LegacyPart[]):
|
||||
|
||||
function currentAssistant(message: typeof SessionV1.Assistant.Type, parts: LegacyPart[]): SessionMessageAssistant {
|
||||
return {
|
||||
id: message.id,
|
||||
id: SessionMessage.ID.make(message.id),
|
||||
type: "assistant",
|
||||
agent: message.agent,
|
||||
model: {
|
||||
agent: Agent.ID.make(message.agent),
|
||||
model: currentModel({
|
||||
id: message.modelID,
|
||||
providerID: message.providerID,
|
||||
...(message.variant ? { variant: message.variant } : {}),
|
||||
},
|
||||
variant: message.variant,
|
||||
}),
|
||||
content: parts.flatMap((part): SessionMessageAssistant["content"] => {
|
||||
if (part.type === "text") return [{ type: "text", text: part.text }]
|
||||
if (part.type === "reasoning")
|
||||
@@ -191,6 +196,14 @@ function currentAssistant(message: typeof SessionV1.Assistant.Type, parts: Legac
|
||||
}
|
||||
}
|
||||
|
||||
function currentModel(input: { id: string; providerID: string; variant?: string }) {
|
||||
return {
|
||||
id: Model.ID.make(input.id),
|
||||
providerID: Provider.ID.make(input.providerID),
|
||||
...(input.variant ? { variant: Model.VariantID.make(input.variant) } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function currentTool(part: typeof SessionV1.ToolPart.Type, fallback: number): SessionMessageAssistantTool {
|
||||
const base = {
|
||||
type: "tool" as const,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionInfo, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import { readShareDocument } from "../../src/core/share-document"
|
||||
import { Share } from "../../src/core/share"
|
||||
import { wire } from "../wire"
|
||||
|
||||
describe("share document", () => {
|
||||
test("rejects malformed current message batches", () => {
|
||||
@@ -32,8 +34,10 @@ describe("share document", () => {
|
||||
|
||||
const result = await readShareDocument(data)
|
||||
|
||||
expect(result.session.id).toBe("ses_current")
|
||||
expect(result.messages).toEqual([{ id: "msg_current", type: "user", text: "Current prompt", time: { created: 1 } }])
|
||||
expect(result.session.id).toBe(wire<SessionInfo["id"]>("ses_current"))
|
||||
expect(result.messages).toEqual(
|
||||
wire<SessionMessageInfo[]>([{ id: "msg_current", type: "user", text: "Current prompt", time: { created: 1 } }]),
|
||||
)
|
||||
})
|
||||
|
||||
test("maps a legacy Session without changing its blob", async () => {
|
||||
@@ -149,31 +153,33 @@ describe("share document", () => {
|
||||
expect(data).toEqual(snapshot)
|
||||
expect(result.session).toMatchObject({ id: sessionID, location: { directory: "/workspace" } })
|
||||
expect(result.session).toMatchObject({ model: { id: "model", providerID: "provider" }, cost: 0 })
|
||||
expect(result.messages).toEqual([
|
||||
{
|
||||
id: messageID,
|
||||
type: "user",
|
||||
text: "Stored prompt\n\nVisible ignored text",
|
||||
metadata: { agent: "build", model: { id: "model", providerID: "provider" } },
|
||||
time: { created: 1 },
|
||||
},
|
||||
{
|
||||
id: assistantID,
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: [
|
||||
{ type: "text", text: "Stored response" },
|
||||
{
|
||||
type: "tool",
|
||||
id: "call_read",
|
||||
name: "read",
|
||||
state: { status: "completed", input: { path: "README.md" }, content: [{ type: "text", text: "hello" }] },
|
||||
time: { created: 2 },
|
||||
},
|
||||
],
|
||||
time: { created: 2, completed: 4 },
|
||||
},
|
||||
])
|
||||
expect(result.messages).toEqual(
|
||||
wire<SessionMessageInfo[]>([
|
||||
{
|
||||
id: messageID,
|
||||
type: "user",
|
||||
text: "Stored prompt\n\nVisible ignored text",
|
||||
metadata: { agent: "build", model: { id: "model", providerID: "provider" } },
|
||||
time: { created: 1 },
|
||||
},
|
||||
{
|
||||
id: assistantID,
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: [
|
||||
{ type: "text", text: "Stored response" },
|
||||
{
|
||||
type: "tool",
|
||||
id: "call_read",
|
||||
name: "read",
|
||||
state: { status: "completed", input: { path: "README.md" }, content: [{ type: "text", text: "hello" }] },
|
||||
time: { created: 2 },
|
||||
},
|
||||
],
|
||||
time: { created: 2, completed: 4 },
|
||||
},
|
||||
]),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Brand } from "effect"
|
||||
|
||||
export type Wire<T> =
|
||||
T extends Brand.Brand<string>
|
||||
? Brand.Brand.Unbranded<T>
|
||||
: T extends ReadonlyArray<infer Item>
|
||||
? Wire<Item>[]
|
||||
: T extends object
|
||||
? { [Key in keyof T]: Wire<T[Key]> }
|
||||
: T
|
||||
|
||||
export function wire<T>(value: Wire<T>): T {
|
||||
return value as T
|
||||
}
|
||||
@@ -35,7 +35,7 @@ export type Contract = {
|
||||
readonly groups: ReadonlyArray<Group>
|
||||
}
|
||||
|
||||
export type EffectTypeReference = {
|
||||
export type TypeReference = {
|
||||
readonly schema: Schema.Top
|
||||
readonly name: string
|
||||
readonly import: string
|
||||
@@ -46,7 +46,7 @@ export type EffectOutputType = {
|
||||
readonly import: string
|
||||
}
|
||||
|
||||
type ResolvedEffectTypeReference = Omit<EffectTypeReference, "schema"> & { readonly ast: SchemaAST.AST }
|
||||
type ResolvedTypeReference = Omit<TypeReference, "schema"> & { readonly ast: SchemaAST.AST }
|
||||
|
||||
export class GenerationError extends Schema.TaggedError<GenerationError>()("GenerationError", {
|
||||
reason: Schema.String,
|
||||
@@ -309,7 +309,7 @@ export function emitEffectImported(
|
||||
export function emitEffectShape(
|
||||
contract: Contract,
|
||||
options?: {
|
||||
readonly typeReferences?: ReadonlyArray<EffectTypeReference>
|
||||
readonly typeReferences?: ReadonlyArray<TypeReference>
|
||||
readonly outputTypes?: Readonly<Record<string, EffectOutputType>>
|
||||
},
|
||||
): Output {
|
||||
@@ -327,6 +327,7 @@ export function emitEffectShape(
|
||||
export function emitPromise(
|
||||
contract: Contract,
|
||||
options?: {
|
||||
readonly typeReferences?: ReadonlyArray<TypeReference>
|
||||
readonly outputTypes?: Readonly<Record<string, { readonly name: string; readonly import: string }>>
|
||||
readonly mutableOutputs?: boolean
|
||||
},
|
||||
@@ -338,7 +339,15 @@ export function emitPromise(
|
||||
return {
|
||||
operations: promiseOperations(groups),
|
||||
files: [
|
||||
{ path: "types.ts", content: renderPromiseTypes(groups, options?.outputTypes, options?.mutableOutputs ?? false) },
|
||||
{
|
||||
path: "types.ts",
|
||||
content: renderPromiseTypes(
|
||||
groups,
|
||||
options?.typeReferences ?? [],
|
||||
options?.outputTypes,
|
||||
options?.mutableOutputs ?? false,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "client-error.ts",
|
||||
content: `export type ClientErrorReason = "Transport" | "UnexpectedStatus" | "UnsupportedContentType" | "MalformedResponse" | "SseEventTooLarge"\n\nexport class ClientError extends Error {\n override readonly name = "ClientError"\n constructor(readonly reason: ClientErrorReason, options?: ErrorOptions) {\n super(reason, options)\n }\n}\n`,
|
||||
@@ -358,10 +367,10 @@ export function emitPromise(
|
||||
|
||||
function renderEffectShape(
|
||||
groups: ReadonlyArray<Group>,
|
||||
typeReferences: ReadonlyArray<EffectTypeReference>,
|
||||
typeReferences: ReadonlyArray<TypeReference>,
|
||||
outputTypes?: Readonly<Record<string, EffectOutputType>>,
|
||||
) {
|
||||
const references = effectTypeReferences(typeReferences)
|
||||
const references = resolveTypeReferences(typeReferences)
|
||||
const imports = new Set<string>()
|
||||
const endpointTypes = groups.map((group, groupIndex) => {
|
||||
const endpoints = group.endpoints.map((endpoint, endpointIndex) => {
|
||||
@@ -422,10 +431,10 @@ ${clientFields.join("\n")}
|
||||
`
|
||||
}
|
||||
|
||||
function effectTypeReferences(input: ReadonlyArray<EffectTypeReference>) {
|
||||
const names = new Map<string, ResolvedEffectTypeReference>()
|
||||
const asts = new Map<SchemaAST.AST, ResolvedEffectTypeReference>()
|
||||
const brands = new Map<string, ResolvedEffectTypeReference>()
|
||||
function resolveTypeReferences(input: ReadonlyArray<TypeReference>) {
|
||||
const names = new Map<string, ResolvedTypeReference>()
|
||||
const asts = new Map<SchemaAST.AST, ResolvedTypeReference>()
|
||||
const brands = new Map<string, ResolvedTypeReference>()
|
||||
for (const reference of input) {
|
||||
const value = { name: reference.name, import: reference.import, ast: reference.schema.ast }
|
||||
const document = SchemaRepresentation.toCodeDocument(
|
||||
@@ -436,7 +445,9 @@ function effectTypeReferences(input: ReadonlyArray<EffectTypeReference>) {
|
||||
name === undefined
|
||||
? undefined
|
||||
: (document.references.nonRecursives.find((item) => item.$ref === name)?.code.Type ?? name)
|
||||
if (type?.includes("Brand.Brand<") && !brands.has(type)) brands.set(type, value)
|
||||
if (type?.includes("Brand.Brand<") && hasRootBrand(reference.schema.ast) && !brands.has(type)) {
|
||||
brands.set(type, value)
|
||||
}
|
||||
if (SchemaAST.resolveIdentifier(reference.schema.ast) !== undefined || type?.includes("Brand.Brand<")) {
|
||||
asts.set(reference.schema.ast, value)
|
||||
asts.set(Schema.toType(reference.schema).ast, value)
|
||||
@@ -445,16 +456,36 @@ function effectTypeReferences(input: ReadonlyArray<EffectTypeReference>) {
|
||||
const previous = names.get(name)
|
||||
if (previous !== undefined) {
|
||||
if (previous.ast !== reference.schema.ast) {
|
||||
throw new GenerationError({ reason: `Conflicting Effect type reference: ${name}` })
|
||||
throw new GenerationError({ reason: `Conflicting type reference: ${name}` })
|
||||
}
|
||||
continue
|
||||
}
|
||||
names.set(name, value)
|
||||
}
|
||||
return { names, asts, brands }
|
||||
return {
|
||||
names,
|
||||
asts,
|
||||
brands: new Map([...brands].sort(([left], [right]) => right.length - left.length)),
|
||||
}
|
||||
}
|
||||
|
||||
function effectType(schema: Schema.Top, references: ReturnType<typeof effectTypeReferences>, imports: Set<string>) {
|
||||
function hasRootBrand(ast: SchemaAST.AST) {
|
||||
const brands = ast.annotations?.brands
|
||||
if (Array.isArray(brands) && brands.length > 0) return true
|
||||
return checksHaveBrand(ast.checks)
|
||||
}
|
||||
|
||||
function checksHaveBrand(checks: SchemaAST.Checks | undefined): boolean {
|
||||
return (
|
||||
checks?.some((check) => {
|
||||
const brands = check.annotations?.brands
|
||||
if (Array.isArray(brands) && brands.length > 0) return true
|
||||
return check._tag === "FilterGroup" && checksHaveBrand(check.checks)
|
||||
}) ?? false
|
||||
)
|
||||
}
|
||||
|
||||
function effectType(schema: Schema.Top, references: ReturnType<typeof resolveTypeReferences>, imports: Set<string>) {
|
||||
const projected = Schema.toType(schema)
|
||||
const direct = references.asts.get(schema.ast) ?? references.asts.get(projected.ast)
|
||||
if (direct !== undefined) {
|
||||
@@ -484,14 +515,23 @@ function effectType(schema: Schema.Top, references: ReturnType<typeof effectType
|
||||
return type
|
||||
}
|
||||
let type = expand(document.codes[0].Type)
|
||||
for (const [brand, reference] of references.brands) {
|
||||
type = replaceBrandReferences(type, references.brands, imports)
|
||||
if (type.includes("Brand.Brand<")) imports.add('import type { Brand } from "effect"')
|
||||
if (type.includes("DateTime.")) imports.add('import type { DateTime } from "effect"')
|
||||
if (type.includes("Schema.")) imports.add('import type { Schema } from "effect"')
|
||||
return type
|
||||
}
|
||||
|
||||
function replaceBrandReferences(
|
||||
type: string,
|
||||
references: ReadonlyMap<string, ResolvedTypeReference>,
|
||||
imports: Set<string>,
|
||||
) {
|
||||
for (const [brand, reference] of references) {
|
||||
if (!type.includes(brand)) continue
|
||||
imports.add(reference.import)
|
||||
type = type.replaceAll(brand, reference.name)
|
||||
}
|
||||
if (type.includes("Brand.Brand<")) imports.add('import type { Brand } from "effect"')
|
||||
if (type.includes("DateTime.")) imports.add('import type { DateTime } from "effect"')
|
||||
if (type.includes("Schema.")) imports.add('import type { Schema } from "effect"')
|
||||
return type
|
||||
}
|
||||
|
||||
@@ -747,9 +787,12 @@ function renderImportedProjection(groups: ReadonlyArray<Group>, endpoints: Reado
|
||||
|
||||
function renderPromiseTypes(
|
||||
groups: ReadonlyArray<Group>,
|
||||
references: ReadonlyArray<TypeReference>,
|
||||
outputTypes?: Readonly<Record<string, { readonly name: string; readonly import: string }>>,
|
||||
mutableOutputs = false,
|
||||
) {
|
||||
const brands = resolveTypeReferences(references).brands
|
||||
const imports = new Set(Object.values(outputTypes ?? {}).map((override) => override.import))
|
||||
const types = new Map<SchemaAST.AST, string>()
|
||||
const typeOf = (schema: Schema.Top, decoded = false) => {
|
||||
const projected = decoded ? Schema.toType(schema) : Schema.toEncoded(schema)
|
||||
@@ -759,6 +802,15 @@ function renderPromiseTypes(
|
||||
types.set(projected.ast, type)
|
||||
return type
|
||||
}
|
||||
const outputFieldTypes = new Map<SchemaAST.AST, string>()
|
||||
const outputFieldTypeOf = (schema: Schema.Top) => {
|
||||
const projected = Schema.toEncoded(schema)
|
||||
const cached = outputFieldTypes.get(projected.ast)
|
||||
if (cached !== undefined) return cached
|
||||
const type = structuralType(projected, brands, imports)
|
||||
outputFieldTypes.set(projected.ast, type)
|
||||
return type
|
||||
}
|
||||
const outputMarkers = new Map<SchemaAST.AST, string>()
|
||||
const outputSchemas: Array<Schema.Top> = []
|
||||
const outputTypeOf = (schema: Schema.Top) => {
|
||||
@@ -782,7 +834,10 @@ function renderPromiseTypes(
|
||||
)
|
||||
const errorTypes = Array.from(errors.values()).map((error) => {
|
||||
const fields = error.fields
|
||||
.map(([name, schema, optional]) => `readonly ${JSON.stringify(name)}${optional ? "?" : ""}: ${typeOf(schema)}`)
|
||||
.map(
|
||||
([name, schema, optional]) =>
|
||||
`readonly ${JSON.stringify(name)}${optional ? "?" : ""}: ${outputFieldTypeOf(schema)}`,
|
||||
)
|
||||
.join("; ")
|
||||
return `export type ${error.identifier} = { readonly ${JSON.stringify(error.key)}: ${JSON.stringify(error.tag)}; ${fields} }\nexport const is${error.identifier} = (value: unknown): value is ${error.identifier} => typeof value === "object" && value !== null && ${JSON.stringify(error.key)} in value && value[${JSON.stringify(error.key)}] === ${JSON.stringify(error.tag)}`
|
||||
})
|
||||
@@ -834,7 +889,7 @@ function renderPromiseTypes(
|
||||
),
|
||||
...Object.values(outputTypes ?? {}).map((output) => output.name),
|
||||
])
|
||||
const rendered = structuralTypes(outputSchemas, mutableOutputs, reservedNames)
|
||||
const rendered = structuralTypes(outputSchemas, mutableOutputs, reservedNames, brands, imports)
|
||||
const resolve = (source: string) =>
|
||||
rendered.types.reduce((result, type, index) => result.replaceAll(`__PROMISE_TYPE_${index}__`, type), source)
|
||||
const resolvedErrors = errorTypes.map(resolve)
|
||||
@@ -844,7 +899,6 @@ function renderPromiseTypes(
|
||||
)
|
||||
? `export type JsonValue = null | boolean | number | string | ${mutableOutputs ? "Array<JsonValue> | { [key: string]: JsonValue }" : "ReadonlyArray<JsonValue> | { readonly [key: string]: JsonValue }"}`
|
||||
: ""
|
||||
const imports = [...new Set(Object.values(outputTypes ?? {}).map((override) => override.import))]
|
||||
return [...imports, json, ...rendered.definitions, ...resolvedErrors, resolvedOperations].filter(Boolean).join("\n\n")
|
||||
}
|
||||
|
||||
@@ -982,7 +1036,13 @@ function identifierPart(value: string) {
|
||||
.join("")
|
||||
}
|
||||
|
||||
function structuralTypes(schemas: ReadonlyArray<Schema.Top>, mutable: boolean, reservedNames: ReadonlySet<string>) {
|
||||
function structuralTypes(
|
||||
schemas: ReadonlyArray<Schema.Top>,
|
||||
mutable: boolean,
|
||||
reservedNames: ReadonlySet<string>,
|
||||
brandReferences: ReadonlyMap<string, ResolvedTypeReference>,
|
||||
imports: Set<string>,
|
||||
) {
|
||||
if (schemas.length === 0) return { types: [], definitions: [] }
|
||||
const representations = SchemaRepresentation.toRepresentations(
|
||||
promiseTypeAsts(schemas) as [SchemaAST.AST, ...Array<SchemaAST.AST>],
|
||||
@@ -1036,7 +1096,7 @@ function structuralTypes(schemas: ReadonlyArray<Schema.Top>, mutable: boolean, r
|
||||
const pattern = `(?<![A-Za-z0-9_$.'"])${reference.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![A-Za-z0-9_$.'"])`
|
||||
type = type.replace(new RegExp(pattern, "g"), name)
|
||||
}
|
||||
const output = type
|
||||
const output = replaceBrandReferences(type, brandReferences, imports)
|
||||
.replaceAll(/ & Brand\.Brand<"[^"]+">/g, "")
|
||||
.replaceAll("Schema.Json", "JsonValue")
|
||||
.replaceAll(/(?<!["'])\bunknown\b(?!["'])/g, "any")
|
||||
@@ -1074,7 +1134,11 @@ function uniqueTypeName(seed: string, used: ReadonlySet<string>, suffix = 1): st
|
||||
return used.has(name) ? uniqueTypeName(seed, used, suffix + 1) : name
|
||||
}
|
||||
|
||||
function structuralType(schema: Schema.Top) {
|
||||
function structuralType(
|
||||
schema: Schema.Top,
|
||||
brandReferences?: ReadonlyMap<string, ResolvedTypeReference>,
|
||||
imports?: Set<string>,
|
||||
) {
|
||||
const document = SchemaRepresentation.toCodeDocument(SchemaRepresentation.toRepresentations([promiseTypeAst(schema)]))
|
||||
if (
|
||||
document.artifacts.some(
|
||||
@@ -1099,8 +1163,12 @@ function structuralType(schema: Schema.Top) {
|
||||
}
|
||||
return type
|
||||
}
|
||||
const type = expand(document.codes[0].Type)
|
||||
return preserveStringSuggestions(
|
||||
expand(document.codes[0].Type)
|
||||
(brandReferences === undefined || imports === undefined
|
||||
? type
|
||||
: replaceBrandReferences(type, brandReferences, imports)
|
||||
)
|
||||
.replaceAll(/ & Brand\.Brand<"[^"]+">/g, "")
|
||||
.replaceAll("Schema.Json", "JsonValue"),
|
||||
)
|
||||
|
||||
@@ -562,6 +562,74 @@ describe("HttpApiCodegen.generate", () => {
|
||||
expect(types).not.toContain("Brand")
|
||||
})
|
||||
|
||||
test("imports authoritative brands into Promise output types", () => {
|
||||
const SessionID = Schema.String.pipe(Schema.brand("SessionID"))
|
||||
const InternalID = Schema.String.pipe(Schema.brand("InternalID"))
|
||||
const Info = Schema.Struct({ id: SessionID, internalID: InternalID }).annotate({ identifier: "Session.Info" })
|
||||
class Missing extends Schema.TaggedError<Missing>()("Missing", { sessionID: SessionID }, { httpApiStatus: 404 }) {}
|
||||
const output = emitPromise(
|
||||
compileContract(
|
||||
api(
|
||||
HttpApiEndpoint.get("get", "/session/:sessionID", {
|
||||
params: { sessionID: SessionID },
|
||||
success: Schema.Struct({ data: Info }),
|
||||
error: Missing,
|
||||
}),
|
||||
),
|
||||
),
|
||||
{
|
||||
typeReferences: [
|
||||
{
|
||||
schema: Info,
|
||||
name: "Session.Info",
|
||||
import: 'import type { Session } from "@example/schema/session"',
|
||||
},
|
||||
{
|
||||
schema: SessionID,
|
||||
name: "Session.ID",
|
||||
import: 'import type { Session } from "@example/schema/session"',
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
const types = output.files.find((file) => file.path === "types.ts")?.content
|
||||
|
||||
expect(types).toContain('import type { Session } from "@example/schema/session"')
|
||||
expect(types).toContain('readonly "sessionID": string')
|
||||
expect(types).toContain('export type SessionInfo = { readonly "id": Session.ID, readonly "internalID": string }')
|
||||
expect(types).toContain('readonly "sessionID": Session.ID')
|
||||
expect(types).not.toContain("Session.Info")
|
||||
expect(types).not.toContain("Brand")
|
||||
})
|
||||
|
||||
test("prefers the most specific registered Promise output brand", () => {
|
||||
const EntityID = Schema.String.pipe(Schema.brand("EntityID"))
|
||||
const SessionID = EntityID.pipe(Schema.brand("SessionID"))
|
||||
const output = emitPromise(
|
||||
compileContract(api(HttpApiEndpoint.get("get", "/session", { success: Schema.Struct({ id: SessionID }) }))),
|
||||
{
|
||||
typeReferences: [
|
||||
{
|
||||
schema: EntityID,
|
||||
name: "Entity.ID",
|
||||
import: 'import type { Entity } from "@example/schema/entity"',
|
||||
},
|
||||
{
|
||||
schema: SessionID,
|
||||
name: "Session.ID",
|
||||
import: 'import type { Session } from "@example/schema/session"',
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
const types = output.files.find((file) => file.path === "types.ts")?.content
|
||||
|
||||
expect(types).toContain('import type { Session } from "@example/schema/session"')
|
||||
expect(types).toContain('export type SessionGetOutput = { readonly "id": Session.ID }')
|
||||
expect(types).not.toContain('import type { Entity } from "@example/schema/entity"')
|
||||
expect(types).not.toContain("Brand")
|
||||
})
|
||||
|
||||
test("preserves suggestions for open string unions in Promise wire types", () => {
|
||||
const Field = Schema.Union([Schema.Literals(["reasoning", "reasoning_content"]), Schema.String]).annotate({
|
||||
identifier: "Field",
|
||||
|
||||
@@ -60,6 +60,7 @@
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@pierre/diffs": "catalog:",
|
||||
|
||||
@@ -10,21 +10,30 @@ import type {
|
||||
SessionMessageUser,
|
||||
SessionStatus,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Form } from "@opencode-ai/schema/form"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Permission } from "@opencode-ai/schema/permission"
|
||||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { Shell } from "@opencode-ai/schema/shell"
|
||||
import { Skill } from "@opencode-ai/schema/skill"
|
||||
import type { SessionDocument } from "../document"
|
||||
import type { SessionUserPresentation } from "../timeline/session-timeline"
|
||||
|
||||
export const CURRENT_SESSION_ID = "session_current_story"
|
||||
export const CURRENT_SESSION_ID = Session.ID.make("session_current_story")
|
||||
export const STORY_TIME = 1_735_689_600_000
|
||||
|
||||
export const STORY_MODEL = {
|
||||
id: "claude-sonnet-4",
|
||||
providerID: "anthropic",
|
||||
variant: "balanced",
|
||||
id: Model.ID.make("claude-sonnet-4"),
|
||||
providerID: Provider.ID.make("anthropic"),
|
||||
variant: Model.VariantID.make("balanced"),
|
||||
} satisfies ModelRef
|
||||
|
||||
function user(id: string, text: string, offset: number): SessionMessageUser {
|
||||
return {
|
||||
id,
|
||||
id: SessionMessage.ID.make(id),
|
||||
type: "user",
|
||||
text,
|
||||
time: { created: STORY_TIME + offset },
|
||||
@@ -42,9 +51,9 @@ function assistant(input: {
|
||||
agent?: string
|
||||
}): SessionMessageAssistant {
|
||||
return {
|
||||
id: input.id,
|
||||
id: SessionMessage.ID.make(input.id),
|
||||
type: "assistant",
|
||||
agent: input.agent ?? "build",
|
||||
agent: Agent.ID.make(input.agent ?? "build"),
|
||||
model: STORY_MODEL,
|
||||
content: input.content,
|
||||
error: input.error,
|
||||
@@ -316,9 +325,9 @@ export const editThenTestDocument = {
|
||||
export const standaloneShellRunningDocument = document(
|
||||
[
|
||||
{
|
||||
id: "msg_shell_running",
|
||||
id: SessionMessage.ID.make("msg_shell_running"),
|
||||
type: "shell",
|
||||
shellID: "shell_running",
|
||||
shellID: Shell.ID.make("sh_running"),
|
||||
command: "bun run storybook --ci",
|
||||
status: "running",
|
||||
output: {
|
||||
@@ -335,9 +344,9 @@ export const standaloneShellRunningDocument = document(
|
||||
|
||||
export const standaloneShellCompletedDocument = document([
|
||||
{
|
||||
id: "msg_shell_completed",
|
||||
id: SessionMessage.ID.make("msg_shell_completed"),
|
||||
type: "shell",
|
||||
shellID: "shell_completed",
|
||||
shellID: Shell.ID.make("sh_completed"),
|
||||
command: "git status --short",
|
||||
status: "exited",
|
||||
exit: 0,
|
||||
@@ -730,16 +739,16 @@ export const webResearchDocument = document([
|
||||
|
||||
export const skillWorkflowDocument = document([
|
||||
{
|
||||
id: "msg_agent_switched_review",
|
||||
id: SessionMessage.ID.make("msg_agent_switched_review"),
|
||||
type: "agent-switched",
|
||||
agent: "review",
|
||||
previous: "build",
|
||||
agent: Agent.ID.make("review"),
|
||||
previous: Agent.ID.make("build"),
|
||||
time: { created: STORY_TIME + 78_000 },
|
||||
},
|
||||
{
|
||||
id: "msg_skill_loaded_rtl",
|
||||
id: SessionMessage.ID.make("msg_skill_loaded_rtl"),
|
||||
type: "skill",
|
||||
skill: "rtl-aware-development",
|
||||
skill: Skill.ID.make("rtl-aware-development"),
|
||||
name: "RTL-aware development",
|
||||
text: "Verify direction independently from language.",
|
||||
time: { created: STORY_TIME + 78_500 },
|
||||
@@ -817,7 +826,7 @@ export const questionPendingDocument = document(
|
||||
)
|
||||
|
||||
export const activeQuestionRequest = {
|
||||
id: "form_session_layout",
|
||||
id: Form.ID.make("frm_session_layout"),
|
||||
sessionID: CURRENT_SESSION_ID,
|
||||
title: "Session layout",
|
||||
metadata: { kind: "question" },
|
||||
@@ -874,7 +883,7 @@ export const compactionDocument = document([
|
||||
error: { type: "ExecutionInterrupted", message: "Context compaction started" },
|
||||
}),
|
||||
{
|
||||
id: "msg_compaction_complete",
|
||||
id: SessionMessage.ID.make("msg_compaction_complete"),
|
||||
type: "compaction",
|
||||
status: "completed",
|
||||
reason: "auto",
|
||||
@@ -1039,7 +1048,7 @@ export const largeCompletedDocument = {
|
||||
} satisfies SessionDocument
|
||||
|
||||
export const activePermissionRequest = {
|
||||
id: "permission_publish_canary",
|
||||
id: Permission.ID.make("permission_publish_canary"),
|
||||
sessionID: CURRENT_SESSION_ID,
|
||||
action: "shell",
|
||||
resources: ["npm publish --tag canary"],
|
||||
|
||||
@@ -8,6 +8,7 @@ import { FileComponentProvider } from "@opencode-ai/ui/context/file"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Show, createSignal, type JSX } from "solid-js"
|
||||
import { CURRENT_SESSION_ID, STORY_TIME } from "./current-session-fixtures"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
|
||||
export function CurrentSessionProviders(props: { document: SessionDocument; children: JSX.Element }) {
|
||||
return (
|
||||
@@ -32,13 +33,13 @@ export function CurrentSessionProviders(props: { document: SessionDocument; chil
|
||||
time: { created: STORY_TIME, updated: STORY_TIME + 300_000 },
|
||||
},
|
||||
{
|
||||
id: "session_child_review",
|
||||
id: Session.ID.make("session_child_review"),
|
||||
parentID: CURRENT_SESSION_ID,
|
||||
title: "Review current Session fixtures",
|
||||
time: { created: STORY_TIME + 71_000, updated: STORY_TIME + 72_000 },
|
||||
},
|
||||
{
|
||||
id: "session_child_tests",
|
||||
id: Session.ID.make("session_child_tests"),
|
||||
parentID: CURRENT_SESSION_ID,
|
||||
title: "Check the Storybook scenarios",
|
||||
time: { created: STORY_TIME + 73_000, updated: STORY_TIME + 74_000 },
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Brand } from "effect"
|
||||
|
||||
export type Wire<T> =
|
||||
T extends Brand.Brand<string>
|
||||
? Brand.Brand.Unbranded<T>
|
||||
: T extends ReadonlyArray<infer Item>
|
||||
? Wire<Item>[]
|
||||
: T extends object
|
||||
? { [Key in keyof T]: Wire<T[Key]> }
|
||||
: T
|
||||
|
||||
export function wire<T>(value: Wire<T>): T {
|
||||
return value as T
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { ModelRef, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import { createTimelineProjection, reuseTimelineRows, TimelineRow, type PartGroup } from "./projection"
|
||||
import { wire } from "../test-fixture"
|
||||
|
||||
const context = (key: string, partIDs: string[], userMessageID = "user-1") =>
|
||||
new TimelineRow.AssistantPart({
|
||||
@@ -96,9 +97,9 @@ describe("reuseTimelineRows", () => {
|
||||
|
||||
describe("createTimelineProjection", () => {
|
||||
test("builds current message, parent, context, and row indexes", () => {
|
||||
const selectedModel = { id: "selected", providerID: "provider" } satisfies ModelRef
|
||||
const assistantModel = { id: "assistant", providerID: "provider", variant: "fast" } satisfies ModelRef
|
||||
const messages = [
|
||||
const selectedModel = wire<ModelRef>({ id: "selected", providerID: "provider" })
|
||||
const assistantModel = wire<ModelRef>({ id: "assistant", providerID: "provider", variant: "fast" })
|
||||
const messages = wire<SessionMessageInfo[]>([
|
||||
{ id: "agent", type: "agent-switched", agent: "explore", time: { created: 1 } },
|
||||
{ id: "model", type: "model-switched", model: selectedModel, time: { created: 2 } },
|
||||
{ id: "user-1", type: "user", text: "first", time: { created: 3 } },
|
||||
@@ -120,7 +121,7 @@ describe("createTimelineProjection", () => {
|
||||
},
|
||||
time: { created: 6 },
|
||||
},
|
||||
] satisfies SessionMessageInfo[]
|
||||
])
|
||||
|
||||
const result = createTimelineProjection({
|
||||
sessionMessages: messages,
|
||||
@@ -130,14 +131,16 @@ describe("createTimelineProjection", () => {
|
||||
|
||||
expect(result.activeMessageID).toBe("user-2")
|
||||
expect(result.messageByID).toBe(result.sessionMessageByID)
|
||||
expect(result.sessionMessageByID.get("assistant-1")).toBe(messages[3])
|
||||
expect(result.assistantMessagesByParent.get("user-1")?.map((message) => message.id)).toEqual(["assistant-1"])
|
||||
expect(result.sessionMessageByID.get(messages[3].id)).toBe(messages[3])
|
||||
expect(result.assistantMessagesByParent.get(messages[2].id)?.map((message) => message.id)).toEqual([messages[3].id])
|
||||
expect(result.assistantMessagesByParent.has("user-2")).toBe(false)
|
||||
expect(result.userContextByID.get("user-1")).toEqual({ agent: "build", model: assistantModel })
|
||||
expect(result.userContextByID.get("user-2")).toEqual({
|
||||
agent: "review",
|
||||
model: { id: "override", providerID: "custom", variant: "precise" },
|
||||
})
|
||||
expect(result.userContextByID.get("user-2")).toEqual(
|
||||
wire<{ agent: string; model: ModelRef }>({
|
||||
agent: "review",
|
||||
model: { id: "override", providerID: "custom", variant: "precise" },
|
||||
}),
|
||||
)
|
||||
expect(result.messageRowIndex.get("user-1")).toBe(0)
|
||||
expect(result.messageLastRowIndex.get("user-1")).toBe(3)
|
||||
expect(result.lastAssistantGroupKey.get("user-1")).toBe("part:assistant-1:assistant-1:text:0")
|
||||
@@ -145,7 +148,7 @@ describe("createTimelineProjection", () => {
|
||||
})
|
||||
|
||||
test("reuses a stable projected row array", () => {
|
||||
const messages = [
|
||||
const messages = wire<SessionMessageInfo[]>([
|
||||
{ id: "user-1", type: "user", text: "first", time: { created: 1 } },
|
||||
{
|
||||
id: "assistant-1",
|
||||
@@ -155,7 +158,7 @@ describe("createTimelineProjection", () => {
|
||||
content: [{ type: "text", text: "answer" }],
|
||||
time: { created: 2, completed: 3 },
|
||||
},
|
||||
] satisfies SessionMessageInfo[]
|
||||
])
|
||||
const first = createTimelineProjection({
|
||||
sessionMessages: messages,
|
||||
status: { type: "idle" },
|
||||
|
||||
@@ -6,6 +6,8 @@ import type {
|
||||
SessionMessageUser,
|
||||
SessionStatus,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
import { Option, Schema } from "effect"
|
||||
import { createMemo, type Accessor } from "solid-js"
|
||||
import { TimelineRow, type PartGroup, type PartRef, type TimelineRowMap } from "./timeline-row"
|
||||
@@ -20,6 +22,7 @@ type PriorContext = { index: number; row: ContextRow }
|
||||
|
||||
const contextTools = new Set(["read", "glob", "grep", "list"])
|
||||
const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
|
||||
const emptyModel: ModelRef = { id: Model.ID.make(""), providerID: Provider.ID.make("") }
|
||||
|
||||
export type TimelineProjectionInput = {
|
||||
sessionMessages: SessionMessageInfo[]
|
||||
@@ -29,7 +32,9 @@ export type TimelineProjectionInput = {
|
||||
}
|
||||
|
||||
export function createTimelineProjection(input: TimelineProjectionInput) {
|
||||
const sessionMessageByID = new Map(input.sessionMessages.map((message) => [message.id, message] as const))
|
||||
const sessionMessageByID = new Map<string, SessionMessageInfo>(
|
||||
input.sessionMessages.map((message) => [message.id, message]),
|
||||
)
|
||||
const projection = Timeline.constructSessionMessageRows(
|
||||
input.sessionMessages,
|
||||
input.showReasoningSummaries,
|
||||
@@ -67,7 +72,7 @@ export function createReactiveTimelineProjection(input: {
|
||||
showReasoningSummaries: Accessor<boolean>
|
||||
}) {
|
||||
const sessionMessageByID = createMemo(
|
||||
() => new Map(input.sessionMessages().map((message) => [message.id, message] as const)),
|
||||
() => new Map<string, SessionMessageInfo>(input.sessionMessages().map((message) => [message.id, message])),
|
||||
)
|
||||
const userContextByID = createMemo(() => indexUserContext(input.sessionMessages()))
|
||||
const assistantMessagesByParent = createMemo(() => indexAssistantMessages(input.sessionMessages()))
|
||||
@@ -334,7 +339,7 @@ export function reuseTimelineRows(previous: TimelineRow.TimelineRow[] | undefine
|
||||
function indexUserContext(messages: SessionMessageInfo[]) {
|
||||
const result = new Map<string, { agent: string; model: ModelRef }>()
|
||||
let agent = ""
|
||||
let model: ModelRef = { id: "", providerID: "" }
|
||||
let model: ModelRef = emptyModel
|
||||
let userID: string | undefined
|
||||
|
||||
messages.forEach((message) => {
|
||||
@@ -362,9 +367,9 @@ function indexUserContext(messages: SessionMessageInfo[]) {
|
||||
localModelID &&
|
||||
typeof localModel.providerID === "string"
|
||||
? {
|
||||
id: localModelID,
|
||||
providerID: localModel.providerID,
|
||||
variant: typeof localModel.variant === "string" ? localModel.variant : undefined,
|
||||
id: Model.ID.make(localModelID),
|
||||
providerID: Provider.ID.make(localModel.providerID),
|
||||
variant: typeof localModel.variant === "string" ? Model.VariantID.make(localModel.variant) : undefined,
|
||||
}
|
||||
: model,
|
||||
})
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import { Timeline, TimelineRow } from "./projection"
|
||||
import { wire } from "../test-fixture"
|
||||
|
||||
describe("current session timeline rows", () => {
|
||||
test("derives turns and tagged rows from chronological current messages", () => {
|
||||
const source = [
|
||||
const source = wire<SessionMessageInfo[]>([
|
||||
{ id: "msg_1", type: "user", text: "first", time: { created: 1 } },
|
||||
{
|
||||
id: "msg_2",
|
||||
@@ -23,7 +24,7 @@ describe("current session timeline rows", () => {
|
||||
content: [{ type: "reasoning", text: "working" }],
|
||||
time: { created: 5 },
|
||||
},
|
||||
] satisfies SessionMessageInfo[]
|
||||
])
|
||||
const result = Timeline.constructSessionMessageRows(source, true, { type: "busy" })
|
||||
|
||||
expect(result.activeMessageID).toBe("msg_3")
|
||||
@@ -37,7 +38,7 @@ describe("current session timeline rows", () => {
|
||||
})
|
||||
|
||||
test("renders a current shell message as a standalone turn", () => {
|
||||
const source = [
|
||||
const source = wire<SessionMessageInfo[]>([
|
||||
{
|
||||
id: "msg_shell",
|
||||
type: "shell",
|
||||
@@ -48,7 +49,7 @@ describe("current session timeline rows", () => {
|
||||
output: { output: "/repo", cursor: 5, size: 5, truncated: false },
|
||||
time: { created: 1, completed: 2 },
|
||||
},
|
||||
] satisfies SessionMessageInfo[]
|
||||
])
|
||||
const result = Timeline.constructSessionMessageRows(source, true, { type: "idle" })
|
||||
|
||||
expect(result.activeMessageID).toBe("msg_shell")
|
||||
@@ -56,7 +57,7 @@ describe("current session timeline rows", () => {
|
||||
})
|
||||
|
||||
test("keeps assistant content when no user root is available", () => {
|
||||
const source = [
|
||||
const source = wire<SessionMessageInfo[]>([
|
||||
{
|
||||
id: "msg_notice",
|
||||
type: "synthetic",
|
||||
@@ -72,7 +73,7 @@ describe("current session timeline rows", () => {
|
||||
content: [{ type: "text", text: "result" }],
|
||||
time: { created: 2, completed: 3 },
|
||||
},
|
||||
] satisfies SessionMessageInfo[]
|
||||
])
|
||||
|
||||
const result = Timeline.constructSessionMessageRows(source, true, { type: "idle" })
|
||||
|
||||
@@ -84,7 +85,7 @@ describe("current session timeline rows", () => {
|
||||
})
|
||||
|
||||
test("keeps CLI notice messages between the assistant steps they surround", () => {
|
||||
const source = [
|
||||
const source = wire<SessionMessageInfo[]>([
|
||||
{ id: "msg_user", type: "user", text: "run", time: { created: 1 } },
|
||||
{ id: "msg_agent", type: "agent-switched", agent: "explore", time: { created: 2 } },
|
||||
{
|
||||
@@ -134,7 +135,7 @@ describe("current session timeline rows", () => {
|
||||
recent: "recent",
|
||||
time: { created: 11 },
|
||||
},
|
||||
] satisfies SessionMessageInfo[]
|
||||
])
|
||||
const result = Timeline.constructSessionMessageRows(source, true, { type: "idle" })
|
||||
|
||||
expect(result.rows.map(TimelineRow.key)).toEqual([
|
||||
@@ -151,10 +152,10 @@ describe("current session timeline rows", () => {
|
||||
})
|
||||
|
||||
test("renders an optimistic user turn and thinking before the protocol message arrives", () => {
|
||||
const source = [
|
||||
const source = wire<SessionMessageInfo[]>([
|
||||
{ id: "msg_z", type: "user", text: "existing", time: { created: 1 } },
|
||||
{ id: "msg_a", type: "user", text: "pending", time: { created: 2 } },
|
||||
] satisfies SessionMessageInfo[]
|
||||
])
|
||||
const result = Timeline.constructSessionMessageRows(source, true, { type: "busy" })
|
||||
|
||||
expect(result.activeMessageID).toBe("msg_a")
|
||||
@@ -167,7 +168,7 @@ describe("current session timeline rows", () => {
|
||||
})
|
||||
|
||||
test("renders retry state from the current assistant message", () => {
|
||||
const source = [
|
||||
const source = wire<SessionMessageInfo[]>([
|
||||
{ id: "msg_user", type: "user", text: "retry", time: { created: 1 } },
|
||||
{
|
||||
id: "msg_assistant",
|
||||
@@ -178,7 +179,7 @@ describe("current session timeline rows", () => {
|
||||
retry: { attempt: 2, at: 10, error: { type: "ProviderError", message: "rate limited" } },
|
||||
time: { created: 2 },
|
||||
},
|
||||
] satisfies SessionMessageInfo[]
|
||||
])
|
||||
|
||||
const result = Timeline.constructSessionMessageRows(source, true, { type: "busy" })
|
||||
|
||||
@@ -186,7 +187,7 @@ describe("current session timeline rows", () => {
|
||||
})
|
||||
|
||||
test("removes a failed assistant error when the turn continues streaming", () => {
|
||||
const source = [
|
||||
const source = wire<SessionMessageInfo[]>([
|
||||
{ id: "msg_user", type: "user", text: "recover", time: { created: 1 } },
|
||||
{
|
||||
id: "msg_failed",
|
||||
@@ -205,14 +206,14 @@ describe("current session timeline rows", () => {
|
||||
content: [{ type: "text", text: "streaming again" }],
|
||||
time: { created: 4 },
|
||||
},
|
||||
] satisfies SessionMessageInfo[]
|
||||
])
|
||||
const result = Timeline.constructSessionMessageRows(source, true, { type: "busy" })
|
||||
|
||||
expect(result.rows.map((row) => row._tag)).toEqual(["UserMessage", "AssistantPart"])
|
||||
})
|
||||
|
||||
test("keeps content IDs and groups adjacent context tools", () => {
|
||||
const source = [
|
||||
const source = wire<SessionMessageInfo[]>([
|
||||
{ id: "msg_user", type: "user", text: "inspect", time: { created: 1 } },
|
||||
{
|
||||
id: "msg_assistant",
|
||||
@@ -254,7 +255,7 @@ describe("current session timeline rows", () => {
|
||||
],
|
||||
time: { created: 2 },
|
||||
},
|
||||
] satisfies SessionMessageInfo[]
|
||||
])
|
||||
|
||||
const result = Timeline.constructSessionMessageRows(source, false, { type: "idle" })
|
||||
const groups = result.rows.flatMap((row) => (row._tag === "AssistantPart" ? [row.group] : []))
|
||||
@@ -277,7 +278,7 @@ describe("current session timeline rows", () => {
|
||||
})
|
||||
|
||||
test("places a divider after interrupted output unless the turn compacts", () => {
|
||||
const messages = [
|
||||
const messages = wire<SessionMessageInfo[]>([
|
||||
{ id: "msg_user", type: "user", text: "continue", time: { created: 1 } },
|
||||
{
|
||||
id: "msg_interrupted",
|
||||
@@ -296,7 +297,7 @@ describe("current session timeline rows", () => {
|
||||
content: [{ type: "text", text: "after" }],
|
||||
time: { created: 4, completed: 5 },
|
||||
},
|
||||
] satisfies SessionMessageInfo[]
|
||||
])
|
||||
|
||||
expect(Timeline.constructSessionMessageRows(messages, true, { type: "idle" }).rows.map((row) => row._tag)).toEqual([
|
||||
"UserMessage",
|
||||
@@ -305,7 +306,7 @@ describe("current session timeline rows", () => {
|
||||
"AssistantPart",
|
||||
])
|
||||
|
||||
const compacted = [
|
||||
const compacted = wire<SessionMessageInfo[]>([
|
||||
messages[0],
|
||||
messages[1],
|
||||
{
|
||||
@@ -318,7 +319,7 @@ describe("current session timeline rows", () => {
|
||||
time: { created: 4 },
|
||||
},
|
||||
messages[2],
|
||||
] satisfies SessionMessageInfo[]
|
||||
])
|
||||
|
||||
expect(Timeline.constructSessionMessageRows(compacted, true, { type: "idle" }).rows.map((row) => row._tag)).toEqual(
|
||||
["UserMessage", "AssistantPart", "Notice", "AssistantPart"],
|
||||
|
||||
@@ -4,6 +4,8 @@ import type {
|
||||
SessionMessageUser,
|
||||
SessionStatus,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
import { Card } from "@opencode-ai/ui/card"
|
||||
import { useI18n } from "@opencode-ai/ui/context/i18n"
|
||||
import { TextReveal } from "@opencode-ai/ui/text-reveal"
|
||||
@@ -22,6 +24,7 @@ import { SessionRetry } from "../components/session-retry"
|
||||
import { createReactiveTimelineProjection, Timeline, TimelineRow } from "./projection"
|
||||
|
||||
const emptyAssistantMessages: SessionMessageAssistant[] = []
|
||||
const emptyModel = { id: Model.ID.make(""), providerID: Provider.ID.make("") }
|
||||
type Projection = ReturnType<typeof createReactiveTimelineProjection>
|
||||
type FramedTimelineRow = Exclude<TimelineRow.TimelineRow, TimelineRow.TurnGap>
|
||||
|
||||
@@ -220,7 +223,7 @@ export function createSessionTimelineRowRenderer(input: {
|
||||
displayText={presentation()?.displayText}
|
||||
comments={presentation()?.comments}
|
||||
historicalAgent={context()?.agent ?? ""}
|
||||
historicalModel={context()?.model ?? { id: "", providerID: "" }}
|
||||
historicalModel={context()?.model ?? emptyModel}
|
||||
actions={input.actions}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
FormFields,
|
||||
FormValue,
|
||||
} from "@opencode-ai/client"
|
||||
import type { Provider } from "@opencode-ai/schema/provider"
|
||||
import open from "open"
|
||||
import { createEffect, createMemo, createSignal, onCleanup, onMount, Show } from "solid-js"
|
||||
import { useClipboard } from "../context/clipboard"
|
||||
@@ -36,7 +37,7 @@ const INTEGRATION_PRIORITY: Record<string, number> = {
|
||||
type ConnectMethod = Exclude<IntegrationInfo["methods"][number], { type: "env" }>
|
||||
type IntegrationAttempt = IntegrationOauthConnectOutput["data"]
|
||||
type CommandAttempt = IntegrationCommandConnectOutput["data"]
|
||||
type OnIntegrationConnected = (providerID?: string) => void
|
||||
type OnIntegrationConnected = (providerID?: Provider.ID) => void
|
||||
const CANCELLED = Symbol("cancelled")
|
||||
const CUSTOM = Symbol("custom")
|
||||
const OPEN = Symbol("open")
|
||||
@@ -95,7 +96,7 @@ export function DialogIntegration(
|
||||
|
||||
const options = createMemo(() => {
|
||||
const providers = data.location.websearch.list() ?? []
|
||||
const providersByID = new Map(providers.map((provider) => [provider.id, provider]))
|
||||
const providersByID = new Map(providers.map((provider) => [String(provider.id), provider]))
|
||||
return integrations().map((integration) => {
|
||||
const methods = connectMethods(integration)
|
||||
const provider = providersByID.get(integration.id)
|
||||
@@ -914,7 +915,7 @@ async function connected(
|
||||
function providerID(data: ReturnType<typeof useData>, integrationID: string) {
|
||||
const models = data.location.model.list() ?? []
|
||||
const matches = (data.location.provider.list() ?? []).filter(
|
||||
(provider) => provider.integrationID === integrationID || provider.id === integrationID,
|
||||
(provider) => provider.integrationID === integrationID || String(provider.id) === integrationID,
|
||||
)
|
||||
return (
|
||||
matches.find((provider) =>
|
||||
|
||||
@@ -9,8 +9,10 @@ import { useConnected } from "./use-connected"
|
||||
import { useData } from "../context/data"
|
||||
import { modelPreferenceKey } from "../model-preference"
|
||||
import { useLocation } from "../context/location"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
|
||||
export function DialogModel(props: { providerID?: string }) {
|
||||
export function DialogModel(props: { providerID?: Provider.ID }) {
|
||||
const local = useLocal()
|
||||
const data = useData()
|
||||
const dialog = useDialog()
|
||||
@@ -19,9 +21,10 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
const favoritePriority = new Set(local.model.favorite().map(modelPreferenceKey))
|
||||
|
||||
const connected = useConnected()
|
||||
const providers = createMemo(
|
||||
() => new Map((data.location.provider.list(location.ref) ?? []).map((item) => [item.id, item])),
|
||||
)
|
||||
const providers = createMemo(() => {
|
||||
const values = data.location.provider.list(location.ref) ?? []
|
||||
return new Map(values.map((item) => [item.id, item]))
|
||||
})
|
||||
const models = createMemo(() => data.location.model.list(location.ref) ?? [])
|
||||
|
||||
const showExtra = createMemo(() => connected() && !props.providerID)
|
||||
@@ -118,12 +121,17 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
if (!value) return "Select model"
|
||||
return value.name
|
||||
})
|
||||
const current = createMemo(() => {
|
||||
const value = local.model.current()
|
||||
if (!value) return
|
||||
return { providerID: Provider.ID.make(value.providerID), modelID: Model.ID.make(value.modelID) }
|
||||
})
|
||||
|
||||
function onSelect(providerID: string, modelID: string) {
|
||||
function onSelect(providerID: Provider.ID, modelID: Model.ID) {
|
||||
local.model.set({ providerID, modelID }, { recent: true })
|
||||
const list = local.model.variant.list()
|
||||
const cur = local.model.variant.current()
|
||||
if (cur && list.includes(cur)) {
|
||||
if (cur && list.some((variant) => variant === cur)) {
|
||||
dialog.clear()
|
||||
return
|
||||
}
|
||||
@@ -163,7 +171,7 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
flat={true}
|
||||
skipFilter={true}
|
||||
title={title()}
|
||||
current={local.model.current()}
|
||||
current={current()}
|
||||
focusCurrent={false}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -143,14 +143,14 @@ export function DialogSessionList() {
|
||||
|
||||
const options = createMemo(() => {
|
||||
const today = new Date().toDateString()
|
||||
const sessionMap = new Map(
|
||||
const sessionMap = new Map<string, SessionInfo>(
|
||||
sessions()
|
||||
.filter((session) => !session.parentID)
|
||||
.map((session) => [session.id, session]),
|
||||
)
|
||||
const pinned = sessionTabs.enabled() ? [] : local.session.pinned().filter((sessionID) => sessionMap.has(sessionID))
|
||||
const pinnedSet = new Set(pinned)
|
||||
const slotByID = new Map(local.session.slots().map((sessionID, index) => [sessionID, index + 1]))
|
||||
const pinnedSet = new Set<string>(pinned)
|
||||
const slotByID = new Map<string, number>(local.session.slots().map((sessionID, index) => [sessionID, index + 1]))
|
||||
|
||||
const option = (session: SessionInfo, category: string) => {
|
||||
const directory = session.location.directory
|
||||
|
||||
@@ -33,6 +33,8 @@ import { createStore, produce, unwrap } from "solid-js/store"
|
||||
import { emptyPrompt, usePromptHistory, type PromptInfo, type PromptPartRef } from "../../prompt/history"
|
||||
import { saveDraft, takeDraft } from "./draft-stash"
|
||||
import { Skill } from "@opencode-ai/schema/skill"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
import { computePromptTraits } from "../../prompt/traits"
|
||||
import { expandPastedTextPlaceholders, expandTrackedPastedText } from "../../prompt/part"
|
||||
import { usePromptStash } from "../../prompt/stash"
|
||||
@@ -1219,9 +1221,9 @@ export function Prompt(props: PromptProps) {
|
||||
location: directory ? { directory } : location,
|
||||
agent: agent.id,
|
||||
model: {
|
||||
providerID: selection.providerID,
|
||||
id: selection.modelID,
|
||||
variant,
|
||||
providerID: Provider.ID.make(selection.providerID),
|
||||
id: Model.ID.make(selection.modelID),
|
||||
variant: variant === undefined ? undefined : Model.VariantID.make(variant),
|
||||
},
|
||||
})
|
||||
sessionID = created.id
|
||||
|
||||
@@ -423,7 +423,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
},
|
||||
current() {
|
||||
const v = this.selected()
|
||||
if (v && this.list().includes(v)) return v
|
||||
if (v && this.list().some((variant) => variant === v)) return v
|
||||
return undefined
|
||||
},
|
||||
list() {
|
||||
@@ -494,7 +494,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
})
|
||||
|
||||
const slots = createMemo(() => {
|
||||
const existing = new Set(
|
||||
const existing = new Set<string>(
|
||||
data.session
|
||||
.list()
|
||||
.filter((x) => x.parentID === undefined)
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
// the synthetic tool parts through the same callbacks used by the live footer.
|
||||
import path from "path"
|
||||
import type { JsonValue, SessionMessageAssistantTool } from "@opencode-ai/client/promise"
|
||||
import { Form } from "@opencode-ai/schema/form"
|
||||
import { Permission } from "@opencode-ai/schema/permission"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { parseSlashHead } from "../prompt/parse"
|
||||
import { writeSessionOutput } from "./stream"
|
||||
import { toolCommit, toolFinalPhase } from "./stream-v2.subagent"
|
||||
@@ -251,7 +254,7 @@ function split(text: string): string[] {
|
||||
return [text.slice(0, size), text.slice(size, size * 2), text.slice(size * 2)]
|
||||
}
|
||||
|
||||
function take(state: State, key: "msg" | "part" | "call" | "perm", prefix: string): string {
|
||||
function take(state: State, key: "msg" | "part" | "call", prefix: string): string {
|
||||
state[key] += 1
|
||||
return `demo_${prefix}_${state[key]}`
|
||||
}
|
||||
@@ -358,7 +361,7 @@ function startTool(state: State, ref: Ref, metadata: Record<string, JsonValue> =
|
||||
function askPermission(state: State, item: Permit): void {
|
||||
const tool = startTool(state, item.ref)
|
||||
|
||||
const id = take(state, "perm", "perm")
|
||||
const id = `per_demo_${++state.perm}`
|
||||
state.perms.set(id, {
|
||||
ref: item.ref,
|
||||
done: item.done,
|
||||
@@ -367,8 +370,8 @@ function askPermission(state: State, item: Permit): void {
|
||||
present(state, [], {
|
||||
type: "permission",
|
||||
request: {
|
||||
id,
|
||||
sessionID: state.id,
|
||||
id: Permission.ID.make(id),
|
||||
sessionID: Session.ID.make(state.id),
|
||||
action: item.permission,
|
||||
resources: item.patterns,
|
||||
metadata: item.metadata ?? {},
|
||||
@@ -800,7 +803,7 @@ function emitForm(state: State, kind: FormKind = "question"): void {
|
||||
startTool(state, ref)
|
||||
state.form++
|
||||
const request: MiniFormRequest = {
|
||||
id: `frm_demo_${state.form}`,
|
||||
id: Form.ID.make(`frm_demo_${state.form}`),
|
||||
sessionID: state.id,
|
||||
title: form.title,
|
||||
metadata:
|
||||
|
||||
@@ -210,7 +210,9 @@ export function Session(props: { verticalTabsWidth: number }) {
|
||||
const pendingUsers = createMemo(() =>
|
||||
data.session.pending.list(route.sessionID).flatMap((item) => (item.type === "user" ? [item] : [])),
|
||||
)
|
||||
const pendingDeliveries = createMemo(() => new Map(pendingUsers().map((item) => [item.id, item.delivery])))
|
||||
const pendingDeliveries = createMemo(
|
||||
() => new Map<string, SessionInbox.Delivery>(pendingUsers().map((item) => [item.id, item.delivery])),
|
||||
)
|
||||
const queuedPrompts = createMemo(() =>
|
||||
pendingUsers().flatMap((item) => (item.delivery === "queue" ? [{ id: item.id, text: item.payload.text }] : [])),
|
||||
)
|
||||
@@ -273,8 +275,8 @@ export function Session(props: { verticalTabsWidth: number }) {
|
||||
if (id === sessionID) setRowsSynced(true)
|
||||
},
|
||||
)
|
||||
const boundaries = createMemo(() => messageBoundaryIDs(rows, messages()))
|
||||
const boundaryIDs = createMemo(() => new Set(boundaries().filter((id) => id !== undefined)))
|
||||
const boundaries = createMemo<string[]>(() => messageBoundaryIDs(rows, messages()).filter((id) => id !== undefined))
|
||||
const boundaryIDs = createMemo(() => new Set<string>(boundaries()))
|
||||
const [navigationMessage, setNavigationMessage] = createSignal<string>()
|
||||
const [navigationSlack, setNavigationSlack] = createSignal(0)
|
||||
const [synced, setSynced] = createSignal(false)
|
||||
|
||||
@@ -24,7 +24,7 @@ export function findMessageBoundary(input: {
|
||||
currentID?: string
|
||||
userOnly?: boolean
|
||||
}) {
|
||||
const messages = new Map(input.messages.map((message) => [message.id, message]))
|
||||
const messages = new Map<string, SessionMessageInfo>(input.messages.map((message) => [message.id, message]))
|
||||
const visible = input.children
|
||||
.flatMap((child) => {
|
||||
if (!child.id) return []
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { createTestRenderer } from "@opentui/core/testing"
|
||||
import { Effect, FileSystem } from "effect"
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { createEventStream, createFetch, directory, json } from "./fixture/tui-client"
|
||||
import { wire } from "./fixture/wire"
|
||||
|
||||
const sessionID = Session.ID.make("ses_dummy")
|
||||
|
||||
test("SIGHUP clears title and disposes scoped resources once", async () => {
|
||||
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
|
||||
@@ -26,7 +31,7 @@ test("SIGHUP clears title and disposes scoped resources once", async () => {
|
||||
const { run } = await import("../src/app")
|
||||
const task = Effect.runPromise(
|
||||
run({
|
||||
app: { name: "test", version: "test", channel: "test" },
|
||||
app: { name: "test", version: "test", channel: "test-sighup" },
|
||||
server: { endpoint: { url: server.url.toString() } },
|
||||
config: { get: async () => ({}), update: async () => ({}) },
|
||||
packages: { resolve: async () => undefined },
|
||||
@@ -68,7 +73,7 @@ test("session lifecycle updates the terminal title and prints the epilogue after
|
||||
let promptRequests = 0
|
||||
const calls = createFetch((url) => {
|
||||
const session = {
|
||||
id: "dummy",
|
||||
id: sessionID,
|
||||
title: "Demo session",
|
||||
projectID: "project",
|
||||
location: { directory },
|
||||
@@ -81,11 +86,11 @@ test("session lifecycle updates the terminal title and prints the epilogue after
|
||||
data: [session],
|
||||
cursor: {},
|
||||
})
|
||||
if (url.pathname === "/api/session/dummy") return json({ data: session })
|
||||
if (url.pathname === "/api/session/dummy/message") return json({ data: [], cursor: {} })
|
||||
if (url.pathname === "/api/session/dummy/inbox") return json({ data: [] })
|
||||
if (url.pathname === "/api/session/dummy/permission") return json({ data: [] })
|
||||
if (url.pathname === "/api/session/dummy/prompt") {
|
||||
if (url.pathname === `/api/session/${sessionID}`) return json({ data: session })
|
||||
if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} })
|
||||
if (url.pathname === `/api/session/${sessionID}/inbox`) return json({ data: [] })
|
||||
if (url.pathname === `/api/session/${sessionID}/permission`) return json({ data: [] })
|
||||
if (url.pathname === `/api/session/${sessionID}/prompt`) {
|
||||
promptRequests++
|
||||
return json({ data: {} })
|
||||
}
|
||||
@@ -102,30 +107,32 @@ test("session lifecycle updates the terminal title and prints the epilogue after
|
||||
const { run } = await import("../src/app")
|
||||
const task = Effect.runPromise(
|
||||
run({
|
||||
app: { name: "test", version: "test", channel: "test" },
|
||||
app: { name: "test", version: "test", channel: "test-session-title" },
|
||||
server: { endpoint: { url: server.url.toString() } },
|
||||
config: { get: async () => ({}), update: async () => ({}) },
|
||||
packages: { resolve: async () => undefined },
|
||||
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
|
||||
args: { sessionID: "dummy" },
|
||||
args: { sessionID },
|
||||
log: () => {},
|
||||
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
|
||||
)
|
||||
|
||||
await initialTitleSet
|
||||
events.emit({
|
||||
id: "evt_renamed",
|
||||
created: 1,
|
||||
type: "session.renamed",
|
||||
durable: { aggregateID: "dummy", seq: 1, version: 1 },
|
||||
data: { sessionID: "dummy", title: "Renamed session" },
|
||||
})
|
||||
events.emit(
|
||||
wire<OpenCodeEvent>({
|
||||
id: "evt_renamed",
|
||||
created: 1,
|
||||
type: "session.renamed",
|
||||
durable: { aggregateID: sessionID, seq: 1, version: 1 },
|
||||
data: { sessionID, title: "Renamed session" },
|
||||
}),
|
||||
)
|
||||
await renamedTitleSet
|
||||
setup.renderer.destroy()
|
||||
await task
|
||||
|
||||
expect(stdout).toContain("Renamed session")
|
||||
expect(stdout).toContain("opencode2 -s dummy")
|
||||
expect(stdout).toContain(`opencode2 -s ${sessionID}`)
|
||||
expect(promptRequests).toBe(0)
|
||||
} finally {
|
||||
process.stdout.write = originalWrite
|
||||
@@ -149,7 +156,7 @@ test("session title generated while an untitled session is loading remains visib
|
||||
const releaseSession = Promise.withResolvers<void>()
|
||||
let sessionRequests = 0
|
||||
const session = {
|
||||
id: "dummy",
|
||||
id: sessionID,
|
||||
projectID: "project",
|
||||
location: { directory },
|
||||
cost: 0,
|
||||
@@ -159,16 +166,16 @@ test("session title generated while an untitled session is loading remains visib
|
||||
const events = createEventStream()
|
||||
const calls = createFetch(async (url) => {
|
||||
if (url.pathname === "/api/session") return json({ data: [], cursor: {} })
|
||||
if (url.pathname === "/api/session/dummy") {
|
||||
if (url.pathname === `/api/session/${sessionID}`) {
|
||||
sessionRequests++
|
||||
sessionRequested.resolve()
|
||||
if (sessionRequests === 2) renameSyncRequested.resolve()
|
||||
await releaseSession.promise
|
||||
return json({ data: session })
|
||||
}
|
||||
if (url.pathname === "/api/session/dummy/message") return json({ data: [], cursor: {} })
|
||||
if (url.pathname === "/api/session/dummy/inbox") return json({ data: [] })
|
||||
if (url.pathname === "/api/session/dummy/permission") return json({ data: [] })
|
||||
if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} })
|
||||
if (url.pathname === `/api/session/${sessionID}/inbox`) return json({ data: [] })
|
||||
if (url.pathname === `/api/session/${sessionID}/permission`) return json({ data: [] })
|
||||
}, events)
|
||||
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
|
||||
|
||||
@@ -176,24 +183,26 @@ test("session title generated while an untitled session is loading remains visib
|
||||
const { run } = await import("../src/app")
|
||||
const task = Effect.runPromise(
|
||||
run({
|
||||
app: { name: "test", version: "test", channel: "test" },
|
||||
app: { name: "test", version: "test", channel: "test-generated-title" },
|
||||
server: { endpoint: { url: server.url.toString() } },
|
||||
config: { get: async () => ({}), update: async () => ({}) },
|
||||
packages: { resolve: async () => undefined },
|
||||
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
|
||||
args: { sessionID: "dummy" },
|
||||
args: { sessionID },
|
||||
log: () => {},
|
||||
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
|
||||
)
|
||||
|
||||
await sessionRequested.promise
|
||||
events.emit({
|
||||
id: "evt_renamed",
|
||||
created: 1,
|
||||
type: "session.renamed",
|
||||
durable: { aggregateID: "dummy", seq: 1, version: 1 },
|
||||
data: { sessionID: "dummy", title: "Generated title" },
|
||||
})
|
||||
events.emit(
|
||||
wire<OpenCodeEvent>({
|
||||
id: "evt_renamed",
|
||||
created: 1,
|
||||
type: "session.renamed",
|
||||
durable: { aggregateID: sessionID, seq: 1, version: 1 },
|
||||
data: { sessionID, title: "Generated title" },
|
||||
}),
|
||||
)
|
||||
await Promise.race([
|
||||
renameSyncRequested.promise,
|
||||
Bun.sleep(2_000).then(() => {
|
||||
@@ -226,7 +235,7 @@ test("session startup prompt is submitted exactly once", async () => {
|
||||
const cwd = process.cwd()
|
||||
const location = { directory: cwd, project: { id: "project", directory: cwd } }
|
||||
const session = {
|
||||
id: "dummy",
|
||||
id: sessionID,
|
||||
title: "Demo session",
|
||||
projectID: "project",
|
||||
location: { directory: cwd },
|
||||
@@ -241,10 +250,10 @@ test("session startup prompt is submitted exactly once", async () => {
|
||||
const calls = createFetch(async (url, request) => {
|
||||
if (url.pathname === "/api/location") return json(location)
|
||||
if (url.pathname === "/api/session") return json({ data: [session], cursor: {} })
|
||||
if (url.pathname === "/api/session/dummy") return json({ data: session })
|
||||
if (url.pathname === "/api/session/dummy/message") return json({ data: [], cursor: {} })
|
||||
if (url.pathname === "/api/session/dummy/inbox") return json({ data: [] })
|
||||
if (url.pathname === "/api/session/dummy/permission") return json({ data: [] })
|
||||
if (url.pathname === `/api/session/${sessionID}`) return json({ data: session })
|
||||
if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} })
|
||||
if (url.pathname === `/api/session/${sessionID}/inbox`) return json({ data: [] })
|
||||
if (url.pathname === `/api/session/${sessionID}/permission`) return json({ data: [] })
|
||||
if (url.pathname === "/api/agent")
|
||||
return json({
|
||||
location,
|
||||
@@ -255,7 +264,7 @@ test("session startup prompt is submitted exactly once", async () => {
|
||||
location,
|
||||
data: [{ id: "model", providerID: "provider", name: "Model", variants: [] }],
|
||||
})
|
||||
if (url.pathname === "/api/session/dummy/prompt") {
|
||||
if (url.pathname === `/api/session/${sessionID}/prompt`) {
|
||||
bodies.push(await request.json())
|
||||
promptSubmitted.resolve()
|
||||
return json({ data: {} })
|
||||
@@ -267,12 +276,12 @@ test("session startup prompt is submitted exactly once", async () => {
|
||||
const { run } = await import("../src/app")
|
||||
const task = Effect.runPromise(
|
||||
run({
|
||||
app: { name: "test", version: "test", channel: "test" },
|
||||
app: { name: "test", version: "test", channel: "test-startup-prompt" },
|
||||
server: { endpoint: { url: server.url.toString() } },
|
||||
config: { get: async () => ({}), update: async () => ({}) },
|
||||
packages: { resolve: async () => undefined },
|
||||
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
|
||||
args: { sessionID: "dummy", prompt: "RESUME_READY" },
|
||||
args: { sessionID, prompt: "RESUME_READY" },
|
||||
log: () => {},
|
||||
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
|
||||
)
|
||||
@@ -307,7 +316,7 @@ test("configured app bindings execute settings and permission commands", async (
|
||||
const { run } = await import("../src/app")
|
||||
const task = Effect.runPromise(
|
||||
run({
|
||||
app: { name: "test", version: "test", channel: "test" },
|
||||
app: { name: "test", version: "test", channel: "test-bindings" },
|
||||
server: { endpoint: { url: server.url.toString() } },
|
||||
config: {
|
||||
get: async () => ({
|
||||
|
||||
@@ -6,12 +6,14 @@ import {
|
||||
credentialConnections,
|
||||
integrationOptions,
|
||||
} from "../../../../src/component/dialog-integration"
|
||||
import { wire, type Wire } from "../../../fixture/wire"
|
||||
|
||||
const integration = (value: Partial<IntegrationInfo> & Pick<IntegrationInfo, "id" | "name">): IntegrationInfo => ({
|
||||
methods: [],
|
||||
connections: [],
|
||||
...value,
|
||||
})
|
||||
const integration = (value: Wire<Partial<IntegrationInfo> & Pick<IntegrationInfo, "id" | "name">>): IntegrationInfo =>
|
||||
wire<IntegrationInfo>({
|
||||
methods: [],
|
||||
connections: [],
|
||||
...value,
|
||||
})
|
||||
|
||||
describe("integrationOptions", () => {
|
||||
test("keeps popular integrations first and sorts the rest alphabetically", () => {
|
||||
@@ -22,7 +24,7 @@ describe("integrationOptions", () => {
|
||||
integration({ id: "custom-z", name: "Zebra" }),
|
||||
integration({ id: "anthropic", name: "Anthropic" }),
|
||||
]).map((item) => item.id),
|
||||
).toEqual(["openai", "anthropic", "mistral", "custom-z"])
|
||||
).toEqual(wire<IntegrationInfo["id"][]>(["openai", "anthropic", "mistral", "custom-z"]))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -57,7 +59,7 @@ describe("credentialConnections", () => {
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toEqual([{ type: "credential", id: "cred_1", label: "Work" }])
|
||||
).toEqual(wire<ReturnType<typeof credentialConnections>>([{ type: "credential", id: "cred_1", label: "Work" }]))
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
|
||||
import Notifications from "../../../../src/feature-plugins/system/notifications"
|
||||
import type { OpenCodeEvent, PermissionAsked } from "@opencode-ai/client"
|
||||
import type { AttentionNotifyOptions, Context } from "@opencode-ai/plugin/tui/context"
|
||||
import { wire, type Wire } from "../../../fixture/wire"
|
||||
|
||||
type Session = { id: string; title: string; parentID?: string }
|
||||
|
||||
@@ -52,30 +53,31 @@ async function setup() {
|
||||
|
||||
return {
|
||||
notifications,
|
||||
emit(event: OpenCodeEvent) {
|
||||
for (const handler of handlers.get(event.type) ?? []) handler(event)
|
||||
emit(event: Wire<OpenCodeEvent>) {
|
||||
const value = wire<OpenCodeEvent>(event)
|
||||
for (const handler of handlers.get(value.type) ?? []) handler(value)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function form(id: string, sessionID = "session"): Extract<OpenCodeEvent, { type: "form.created" }>["data"]["form"] {
|
||||
return {
|
||||
return wire<Extract<OpenCodeEvent, { type: "form.created" }>["data"]["form"]>({
|
||||
id,
|
||||
sessionID,
|
||||
title: "Input requested",
|
||||
fields: [{ key: "authorization", type: "external", url: "https://example.com" }],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function permission(id: string, sessionID = "session"): PermissionAsked["data"] {
|
||||
return {
|
||||
return wire<PermissionAsked["data"]>({
|
||||
id,
|
||||
sessionID,
|
||||
action: "edit",
|
||||
resources: [],
|
||||
metadata: {},
|
||||
save: [],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function durable(sessionID: string): { aggregateID: string; seq: number; version: 1 } {
|
||||
@@ -83,27 +85,27 @@ function durable(sessionID: string): { aggregateID: string; seq: number; version
|
||||
}
|
||||
|
||||
function executionStarted(id: string, sessionID = "session"): OpenCodeEvent {
|
||||
return {
|
||||
return wire<OpenCodeEvent>({
|
||||
id,
|
||||
created: 0,
|
||||
type: "session.execution.started",
|
||||
durable: durable(sessionID),
|
||||
data: { sessionID },
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function executionSucceeded(id: string, sessionID = "session"): OpenCodeEvent {
|
||||
return {
|
||||
return wire<OpenCodeEvent>({
|
||||
id,
|
||||
created: 0,
|
||||
type: "session.execution.succeeded",
|
||||
durable: durable(sessionID),
|
||||
data: { sessionID },
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function executionFailed(id: string, sessionID = "session"): OpenCodeEvent {
|
||||
return {
|
||||
return wire<OpenCodeEvent>({
|
||||
id,
|
||||
created: 0,
|
||||
type: "session.execution.failed",
|
||||
@@ -112,7 +114,7 @@ function executionFailed(id: string, sessionID = "session"): OpenCodeEvent {
|
||||
sessionID,
|
||||
error: { type: "unknown", message: "boom" },
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const formNotification: AttentionNotifyOptions = {
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { expect, test } from "bun:test"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client"
|
||||
import type { OpenCodeEvent, SessionInboxInfo } from "@opencode-ai/client"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { Form } from "@opencode-ai/schema/form"
|
||||
import { Permission } from "@opencode-ai/schema/permission"
|
||||
import { Project } from "@opencode-ai/schema/project"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { Shell } from "@opencode-ai/schema/shell"
|
||||
import { WebSearch } from "@opencode-ai/schema/websearch"
|
||||
import { Workspace } from "@opencode-ai/schema/workspace"
|
||||
import { createEffect, onMount, type ParentProps } from "solid-js"
|
||||
import { ConfigProvider } from "../../../src/config"
|
||||
import { ClientProvider, useClient } from "../../../src/context/client"
|
||||
@@ -19,6 +27,7 @@ import { createApi, createEventStream, createFetch, directory, json, worktree }
|
||||
import { emptyThemeSource } from "../../fixture/fixture"
|
||||
import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
import { wire, type Wire } from "../../fixture/wire"
|
||||
|
||||
const formFields = [{ key: "authorization", type: "external", url: "https://example.com" }] satisfies [
|
||||
{
|
||||
@@ -36,8 +45,8 @@ async function wait(fn: () => boolean, timeout = 2000) {
|
||||
}
|
||||
}
|
||||
|
||||
function emitEvent(events: ReturnType<typeof createEventStream>, event: OpenCodeEvent) {
|
||||
events.emit({ ...event, location: { directory } })
|
||||
function emitEvent(events: ReturnType<typeof createEventStream>, event: Wire<OpenCodeEvent>) {
|
||||
events.emit(wire<OpenCodeEvent>({ ...event, location: { directory } }))
|
||||
}
|
||||
|
||||
const config = createTuiResolvedConfig()
|
||||
@@ -194,14 +203,14 @@ test("proactively syncs project metadata newest first", async () => {
|
||||
await wait(() => data.project.get("proj_test") !== undefined)
|
||||
expect(data.project.list()).toEqual([
|
||||
{
|
||||
id: "proj_test",
|
||||
id: Project.ID.make("proj_test"),
|
||||
canonical: worktree,
|
||||
name: "OpenCode",
|
||||
time: { created: 1, updated: 2 },
|
||||
sandboxes: [],
|
||||
},
|
||||
{
|
||||
id: "proj_old",
|
||||
id: Project.ID.make("proj_old"),
|
||||
canonical: "/old/project",
|
||||
name: "Old project",
|
||||
time: { created: 1, updated: 1 },
|
||||
@@ -366,13 +375,20 @@ test("refreshes resources into reactive getters", async () => {
|
||||
await data.location.websearch.refresh()
|
||||
|
||||
expect(data.session.get("ses_test")?.title).toBe("Test session")
|
||||
expect(data.session.message.list("ses_test").map((message) => message.id)).toEqual(["msg_first", "msg_second"])
|
||||
expect(data.session.message.get("ses_test", "msg_second")?.id).toBe("msg_second")
|
||||
expect(data.session.message.list("ses_test").map((message) => message.id)).toEqual([
|
||||
SessionMessage.ID.make("msg_first"),
|
||||
SessionMessage.ID.make("msg_second"),
|
||||
])
|
||||
expect(data.session.message.get("ses_test", SessionMessage.ID.make("msg_second"))?.id).toBe(
|
||||
SessionMessage.ID.make("msg_second"),
|
||||
)
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame()).toContain("msg_second")
|
||||
expect(data.location.default()).toEqual({ directory, workspaceID: undefined })
|
||||
expect(data.location.agent.list(location)?.map((agent) => agent.id)).toEqual(["build"])
|
||||
expect(data.location.websearch.list(location)).toEqual([{ id: "standalone", name: "Standalone" }])
|
||||
expect(data.location.agent.list(location)?.map((agent) => agent.id)).toEqual([Agent.ID.make("build")])
|
||||
expect(data.location.websearch.list(location)).toEqual([
|
||||
{ id: WebSearch.ID.make("standalone"), name: "Standalone" },
|
||||
])
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
@@ -588,7 +604,9 @@ test("truncates committed revert messages without changing lifetime usage", asyn
|
||||
})
|
||||
await wait(() => data.session.message.list(sessionID).length === 1)
|
||||
expect(data.session.get(sessionID)?.cost).toBe(0.75)
|
||||
expect(data.session.message.list(sessionID).map((message) => message.id)).toEqual(["msg_revert_boundary"])
|
||||
expect(data.session.message.list(sessionID).map((message) => message.id)).toEqual([
|
||||
SessionMessage.ID.make("msg_revert_boundary"),
|
||||
])
|
||||
expect(data.session.get(sessionID)?.revert).toBeUndefined()
|
||||
expect(data.session.get(sessionID)?.tokens).toEqual(tokens)
|
||||
} finally {
|
||||
@@ -653,17 +671,17 @@ test("updates session location when moved", async () => {
|
||||
},
|
||||
})
|
||||
await wait(() => data.session.get("ses_test")?.location.directory === destination)
|
||||
expect(data.session.get("ses_test")?.projectID).toBe("project-moved")
|
||||
expect(data.session.get("ses_test")?.projectID).toBe(Project.ID.make("project-moved"))
|
||||
expect(data.session.get("ses_test")?.subpath).toBe("packages/cli")
|
||||
expect(data.session.message.list("ses_test")).toContainEqual({
|
||||
id: "msg_moved_1",
|
||||
id: SessionMessage.ID.make("msg_moved_1"),
|
||||
type: "location-switched",
|
||||
location: { directory: destination },
|
||||
projectID: "project-moved",
|
||||
projectID: Project.ID.make("project-moved"),
|
||||
subpath: "packages/cli",
|
||||
previous: {
|
||||
location: { directory },
|
||||
projectID: "proj_test",
|
||||
projectID: Project.ID.make("proj_test"),
|
||||
},
|
||||
time: { created: 1 },
|
||||
})
|
||||
@@ -736,6 +754,8 @@ test("restores running manual compaction before applying live deltas", async ()
|
||||
|
||||
test("reconnects the event stream and resyncs active data", async () => {
|
||||
const events = createEventStream()
|
||||
const staleMessageID = wire<SessionMessage.ID>("message-stale")
|
||||
const freshMessageID = wire<SessionMessage.ID>("message-fresh")
|
||||
const requests = { active: 0, event: 0, message: 0, model: 0 }
|
||||
let resolveActive!: (response: Response) => void
|
||||
let resolveMessages!: (response: Response) => void
|
||||
@@ -755,7 +775,7 @@ test("reconnects the event stream and resyncs active data", async () => {
|
||||
requests.message++
|
||||
if (requests.message === 1)
|
||||
return json({
|
||||
data: [{ id: "message-stale", type: "user", text: "Stale", time: { created: 1 } }],
|
||||
data: [{ id: staleMessageID, type: "user", text: "Stale", time: { created: 1 } }],
|
||||
cursor: {},
|
||||
})
|
||||
return new Promise<Response>((resolve) => {
|
||||
@@ -808,7 +828,7 @@ test("reconnects the event stream and resyncs active data", async () => {
|
||||
await wait(() => data.location.model.list()?.[0]?.id === "model-1")
|
||||
await wait(() => data.session.status("session-stale") === "running")
|
||||
await data.session.message.sync("session-stale")
|
||||
expect(data.session.message.get("session-stale", "message-stale")?.id).toBe("message-stale")
|
||||
expect(data.session.message.get("session-stale", staleMessageID)?.id).toBe(staleMessageID)
|
||||
expect(client.connection.status()).toBe("connected")
|
||||
expect(client.connection.attempt()).toBe(0)
|
||||
|
||||
@@ -824,15 +844,15 @@ test("reconnects the event stream and resyncs active data", async () => {
|
||||
await wait(() => data.location.model.list()?.[0]?.id === "model-2", 4000)
|
||||
await wait(() => data.session.status("session-stale") === "idle")
|
||||
await wait(() => requests.message === 2)
|
||||
expect(data.session.message.get("session-stale", "message-stale")?.id).toBe("message-stale")
|
||||
expect(data.session.message.get("session-stale", staleMessageID)?.id).toBe(staleMessageID)
|
||||
resolveMessages(
|
||||
json({
|
||||
data: [{ id: "message-fresh", type: "user", text: "Fresh", time: { created: 2 } }],
|
||||
data: [{ id: freshMessageID, type: "user", text: "Fresh", time: { created: 2 } }],
|
||||
cursor: {},
|
||||
}),
|
||||
)
|
||||
await wait(() => data.session.message.get("session-stale", "message-fresh") !== undefined)
|
||||
expect(data.session.message.get("session-stale", "message-stale")).toBeUndefined()
|
||||
await wait(() => data.session.message.get("session-stale", freshMessageID) !== undefined)
|
||||
expect(data.session.message.get("session-stale", staleMessageID)).toBeUndefined()
|
||||
await wait(() => data.session.status("session-new") === "running")
|
||||
expect(requests.event).toBe(2)
|
||||
expect(requests.message).toBe(2)
|
||||
@@ -933,6 +953,8 @@ test("completes exploration when a queued prompt is promoted", async () => {
|
||||
test("updates and removes queued inputs from durable lifecycle events", async () => {
|
||||
const events = createEventStream()
|
||||
const sessionID = "session-queue-management"
|
||||
const queuedID = wire<SessionMessage.ID>("message-queued")
|
||||
const cancelledID = wire<SessionMessage.ID>("message-cancelled")
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} })
|
||||
}, events)
|
||||
@@ -968,40 +990,40 @@ test("updates and removes queued inputs from durable lifecycle events", async ()
|
||||
durable: durable(sessionID),
|
||||
data: {
|
||||
sessionID,
|
||||
inboxID: "message-queued",
|
||||
inboxID: queuedID,
|
||||
item: { type: "user", payload: { text: "Steer me" }, delivery: "queue" },
|
||||
},
|
||||
})
|
||||
await wait(() => data.session.pending.list(sessionID).length === 1)
|
||||
expect(rows).not.toContainEqual({ type: "message", messageID: "message-queued" })
|
||||
expect(rows).not.toContainEqual({ type: "message", messageID: queuedID })
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_queue_steered",
|
||||
created: 2,
|
||||
type: "session.inbox.delivery.changed",
|
||||
durable: durable(sessionID, 1),
|
||||
data: { sessionID, inboxID: "message-queued", delivery: "steer" },
|
||||
data: { sessionID, inboxID: queuedID, delivery: "steer" },
|
||||
})
|
||||
await wait(() =>
|
||||
data.session.pending
|
||||
.list(sessionID)
|
||||
.some((item) => item.id === "message-queued" && item.type !== "compaction" && item.delivery === "steer"),
|
||||
.some((item) => item.id === queuedID && item.type !== "compaction" && item.delivery === "steer"),
|
||||
)
|
||||
expect(rows).toContainEqual({ type: "message", messageID: "message-queued" })
|
||||
expect(rows).toContainEqual({ type: "message", messageID: queuedID })
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_queue_restored",
|
||||
created: 3,
|
||||
type: "session.inbox.delivery.changed",
|
||||
durable: durable(sessionID, 2),
|
||||
data: { sessionID, inboxID: "message-queued", delivery: "queue" },
|
||||
data: { sessionID, inboxID: queuedID, delivery: "queue" },
|
||||
})
|
||||
await wait(() =>
|
||||
data.session.pending
|
||||
.list(sessionID)
|
||||
.some((item) => item.id === "message-queued" && item.type !== "compaction" && item.delivery === "queue"),
|
||||
.some((item) => item.id === queuedID && item.type !== "compaction" && item.delivery === "queue"),
|
||||
)
|
||||
expect(rows).not.toContainEqual({ type: "message", messageID: "message-queued" })
|
||||
expect(rows).not.toContainEqual({ type: "message", messageID: queuedID })
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_cancel_admitted",
|
||||
@@ -1010,7 +1032,7 @@ test("updates and removes queued inputs from durable lifecycle events", async ()
|
||||
durable: durable(sessionID, 3),
|
||||
data: {
|
||||
sessionID,
|
||||
inboxID: "message-cancelled",
|
||||
inboxID: cancelledID,
|
||||
item: { type: "user", payload: { text: "Delete me" }, delivery: "queue" },
|
||||
},
|
||||
})
|
||||
@@ -1020,11 +1042,11 @@ test("updates and removes queued inputs from durable lifecycle events", async ()
|
||||
created: 5,
|
||||
type: "session.inbox.cancelled",
|
||||
durable: durable(sessionID, 4),
|
||||
data: { sessionID, inboxID: "message-cancelled" },
|
||||
data: { sessionID, inboxID: cancelledID },
|
||||
})
|
||||
await wait(() => !data.session.input.has(sessionID, "message-cancelled"))
|
||||
expect(data.session.pending.list(sessionID).map((item) => item.id)).toEqual(["message-queued"])
|
||||
expect(data.session.message.get(sessionID, "message-cancelled")).toBeUndefined()
|
||||
await wait(() => !data.session.input.has(sessionID, cancelledID))
|
||||
expect(data.session.pending.list(sessionID).map((item) => item.id)).toEqual([queuedID])
|
||||
expect(data.session.message.get(sessionID, cancelledID)).toBeUndefined()
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
@@ -1125,7 +1147,9 @@ test("removes committed revert messages from local state", async () => {
|
||||
})
|
||||
|
||||
await wait(() => data.session.message.list(sessionID).length === 1)
|
||||
expect(data.session.message.list(sessionID).map((message) => message.id)).toEqual(["msg_001"])
|
||||
expect(data.session.message.list(sessionID).map((message) => message.id)).toEqual([
|
||||
SessionMessage.ID.make("msg_001"),
|
||||
])
|
||||
expect(data.session.message.get(sessionID, "msg_002")).toBeUndefined()
|
||||
expect(data.session.message.get(sessionID, "msg_003")).toBeUndefined()
|
||||
} finally {
|
||||
@@ -1575,9 +1599,11 @@ test("tracks session status from active sessions and execution events", async ()
|
||||
test("restores queued compaction from durable pending input", async () => {
|
||||
const events = createEventStream()
|
||||
const sessionID = "session-compaction-queued"
|
||||
let pending = [
|
||||
const queuedID = wire<SessionMessage.ID>("message-compaction-queued")
|
||||
const laterID = wire<SessionMessage.ID>("message-compaction-later")
|
||||
let pending = wire<SessionInboxInfo[]>([
|
||||
{
|
||||
id: "message-compaction-queued",
|
||||
id: queuedID,
|
||||
sessionID,
|
||||
timeCreated: 1,
|
||||
type: "compaction" as const,
|
||||
@@ -1585,14 +1611,14 @@ test("restores queued compaction from durable pending input", async () => {
|
||||
delivery: "queue" as const,
|
||||
},
|
||||
{
|
||||
id: "message-compaction-later",
|
||||
id: laterID,
|
||||
sessionID,
|
||||
timeCreated: 2,
|
||||
type: "compaction" as const,
|
||||
payload: {},
|
||||
delivery: "queue" as const,
|
||||
},
|
||||
]
|
||||
])
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname !== `/api/session/${sessionID}/inbox`) return
|
||||
return json({ data: pending })
|
||||
@@ -1623,10 +1649,7 @@ test("restores queued compaction from durable pending input", async () => {
|
||||
try {
|
||||
await wait(() => client.connection.status() === "connected")
|
||||
await wait(() => data.session.pending.list(sessionID).length === 2)
|
||||
expect(data.session.pending.list(sessionID).map((item) => item.id)).toEqual([
|
||||
"message-compaction-queued",
|
||||
"message-compaction-later",
|
||||
])
|
||||
expect(data.session.pending.list(sessionID).map((item) => item.id)).toEqual([queuedID, laterID])
|
||||
await wait(() => rows.filter((row) => row.type === "compaction-queued").length === 2)
|
||||
expect(rows.filter((row) => row.type === "compaction-queued")).toEqual([
|
||||
{ type: "compaction-queued", inboxID: "message-compaction-queued" },
|
||||
@@ -1680,11 +1703,11 @@ test("restores queued compaction from durable pending input", async () => {
|
||||
sessionID,
|
||||
reason: "manual",
|
||||
recent: "",
|
||||
inputID: "message-compaction-queued",
|
||||
inputID: queuedID,
|
||||
},
|
||||
})
|
||||
await wait(() => data.session.pending.list(sessionID).length === 1)
|
||||
expect(data.session.pending.list(sessionID).map((item) => item.id)).toEqual(["message-compaction-later"])
|
||||
expect(data.session.pending.list(sessionID).map((item) => item.id)).toEqual([laterID])
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_compaction_ended",
|
||||
@@ -1693,7 +1716,7 @@ test("restores queued compaction from durable pending input", async () => {
|
||||
durable: durable(sessionID, 7),
|
||||
data: { sessionID, reason: "manual", text: "Summary", recent: "" },
|
||||
})
|
||||
expect(data.session.pending.list(sessionID).map((item) => item.id)).toEqual(["message-compaction-later"])
|
||||
expect(data.session.pending.list(sessionID).map((item) => item.id)).toEqual([laterID])
|
||||
|
||||
pending = []
|
||||
data.session.pending.invalidate(sessionID)
|
||||
@@ -1967,7 +1990,7 @@ test("refreshes references after updates", async () => {
|
||||
test("keeps shell state scoped to location", async () => {
|
||||
const events = createEventStream()
|
||||
const other = "/tmp/opencode/other"
|
||||
const workspace = "ws_other"
|
||||
const workspace = wire<Workspace.ID>("ws_other")
|
||||
let removed: URL | undefined
|
||||
const calls = createFetch((url, request) => {
|
||||
if (url.pathname === "/api/shell/sh_other" && request.method === "DELETE") {
|
||||
@@ -2028,8 +2051,10 @@ test("keeps shell state scoped to location", async () => {
|
||||
await wait(() => data.shell.list().some((shell) => shell.id === "sh_default"))
|
||||
await data.shell.sync({ directory: other, workspaceID: workspace })
|
||||
|
||||
expect(data.shell.list().map((shell) => shell.id)).toEqual(["sh_default"])
|
||||
expect(data.shell.list({ directory: other, workspaceID: workspace }).map((shell) => shell.id)).toEqual(["sh_other"])
|
||||
expect(data.shell.list().map((shell) => shell.id)).toEqual([Shell.ID.make("sh_default")])
|
||||
expect(data.shell.list({ directory: other, workspaceID: workspace }).map((shell) => shell.id)).toEqual([
|
||||
Shell.ID.make("sh_other"),
|
||||
])
|
||||
expect(data.shell.listBySession("ses_shared").map((shell) => [shell.id, shell.location.directory])).toEqual([
|
||||
["sh_default", directory],
|
||||
["sh_other", other],
|
||||
@@ -2042,28 +2067,30 @@ test("keeps shell state scoped to location", async () => {
|
||||
expect(removed?.searchParams.get("location[directory]")).toBe(other)
|
||||
expect(removed?.searchParams.get("location[workspace]")).toBe(workspace)
|
||||
|
||||
events.emit({
|
||||
id: "evt_shell_created",
|
||||
created: 0,
|
||||
type: "shell.created",
|
||||
location: { directory: other, workspaceID: workspace },
|
||||
data: {
|
||||
info: {
|
||||
id: "sh_live_other",
|
||||
status: "running",
|
||||
command: "npm run watch",
|
||||
cwd: other,
|
||||
shell: "/bin/sh",
|
||||
file: "/tmp/opencode-shell-live",
|
||||
metadata: { sessionID: "ses_shared" },
|
||||
time: { started: 2 },
|
||||
events.emit(
|
||||
wire<OpenCodeEvent>({
|
||||
id: "evt_shell_created",
|
||||
created: 0,
|
||||
type: "shell.created",
|
||||
location: { directory: other, workspaceID: workspace },
|
||||
data: {
|
||||
info: {
|
||||
id: "sh_live_other",
|
||||
status: "running",
|
||||
command: "npm run watch",
|
||||
cwd: other,
|
||||
shell: "/bin/sh",
|
||||
file: "/tmp/opencode-shell-live",
|
||||
metadata: { sessionID: "ses_shared" },
|
||||
time: { started: 2 },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
await wait(() =>
|
||||
data.shell.list({ directory: other, workspaceID: workspace }).some((shell) => shell.id === "sh_live_other"),
|
||||
)
|
||||
expect(data.shell.list().map((shell) => shell.id)).toEqual(["sh_default"])
|
||||
expect(data.shell.list().map((shell) => shell.id)).toEqual([Shell.ID.make("sh_default")])
|
||||
expect(
|
||||
data.shell.listBySession("ses_shared").find((shell) => shell.id === "sh_live_other")?.location.directory,
|
||||
).toBe(other)
|
||||
@@ -2129,7 +2156,7 @@ test("adds and dismisses permission requests from live events", async () => {
|
||||
data: { sessionID: "ses_1", requestID: "per_1", reply: "once" },
|
||||
})
|
||||
await wait(() => data.session.permission.list("ses_1")?.length === 1)
|
||||
expect(data.session.permission.list("ses_1")?.[0]?.id).toBe("per_2")
|
||||
expect(data.session.permission.list("ses_1")?.[0]?.id).toBe(Permission.ID.make("per_2"))
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_permission_replied_2",
|
||||
@@ -2317,7 +2344,7 @@ test("adds, dismisses, and refreshes form requests", async () => {
|
||||
await wait(() => data.session.form.list("ses_1")?.length === 0)
|
||||
|
||||
await data.session.form.sync("ses_1")
|
||||
expect(data.session.form.list("ses_1")?.map((form) => form.id)).toEqual(["frm_remote"])
|
||||
expect(data.session.form.list("ses_1")?.map((form) => form.id)).toEqual([Form.ID.make("frm_remote")])
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
@@ -2326,7 +2353,10 @@ test("adds, dismisses, and refreshes form requests", async () => {
|
||||
test("tracks global forms by location", async () => {
|
||||
const events = createEventStream()
|
||||
const calls = createFetch(undefined, events)
|
||||
const other = { directory: "/tmp/opencode-other", workspaceID: "wrk_other" }
|
||||
const other = wire<{ directory: string; workspaceID: Workspace.ID }>({
|
||||
directory: "/tmp/opencode-other",
|
||||
workspaceID: "wrk_other",
|
||||
})
|
||||
let data!: ReturnType<typeof useData>
|
||||
let client!: ReturnType<typeof useClient>
|
||||
|
||||
@@ -2350,39 +2380,47 @@ test("tracks global forms by location", async () => {
|
||||
|
||||
try {
|
||||
await wait(() => client.connection.status() === "connected")
|
||||
events.emit({
|
||||
id: "evt_form_created_global_other",
|
||||
created: 0,
|
||||
location: other,
|
||||
type: "form.created",
|
||||
data: {
|
||||
form: { id: "frm_other", sessionID: "global", title: "Input requested", fields: formFields },
|
||||
},
|
||||
})
|
||||
events.emit(
|
||||
wire<OpenCodeEvent>({
|
||||
id: "evt_form_created_global_other",
|
||||
created: 0,
|
||||
location: other,
|
||||
type: "form.created",
|
||||
data: {
|
||||
form: { id: "frm_other", sessionID: "global", title: "Input requested", fields: formFields },
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
await wait(() => data.session.form.list("global", other)?.length === 1)
|
||||
expect(data.session.form.list("global", { directory }) ?? []).toEqual([])
|
||||
|
||||
events.emit({
|
||||
id: "evt_form_created_global_default",
|
||||
created: 1,
|
||||
location: { directory },
|
||||
type: "form.created",
|
||||
data: {
|
||||
form: { id: "frm_default", sessionID: "global", title: "Input requested", fields: formFields },
|
||||
},
|
||||
})
|
||||
events.emit(
|
||||
wire<OpenCodeEvent>({
|
||||
id: "evt_form_created_global_default",
|
||||
created: 1,
|
||||
location: { directory },
|
||||
type: "form.created",
|
||||
data: {
|
||||
form: { id: "frm_default", sessionID: "global", title: "Input requested", fields: formFields },
|
||||
},
|
||||
}),
|
||||
)
|
||||
await wait(() => data.session.form.list("global", { directory })?.length === 1)
|
||||
|
||||
events.emit({
|
||||
id: "evt_form_replied_global_other",
|
||||
created: 2,
|
||||
location: other,
|
||||
type: "form.replied",
|
||||
data: { id: "frm_other", sessionID: "global", answer: {} },
|
||||
})
|
||||
events.emit(
|
||||
wire<OpenCodeEvent>({
|
||||
id: "evt_form_replied_global_other",
|
||||
created: 2,
|
||||
location: other,
|
||||
type: "form.replied",
|
||||
data: { id: "frm_other", sessionID: "global", answer: {} },
|
||||
}),
|
||||
)
|
||||
await wait(() => data.session.form.list("global", other)?.length === 0)
|
||||
expect(data.session.form.list("global", { directory })?.map((form) => form.id)).toEqual(["frm_default"])
|
||||
expect(data.session.form.list("global", { directory })?.map((form) => form.id)).toEqual([
|
||||
Form.ID.make("frm_default"),
|
||||
])
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
@@ -2391,7 +2429,10 @@ test("tracks global forms by location", async () => {
|
||||
test("syncs global forms once for each requested location", async () => {
|
||||
const events = createEventStream()
|
||||
const requests: URL[] = []
|
||||
const other = { directory: "/tmp/opencode-other", workspaceID: "wrk_other" }
|
||||
const other = wire<{ directory: string; workspaceID: Workspace.ID }>({
|
||||
directory: "/tmp/opencode-other",
|
||||
workspaceID: "wrk_other",
|
||||
})
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname !== "/api/form/request") return
|
||||
requests.push(url)
|
||||
@@ -2444,8 +2485,10 @@ test("syncs global forms once for each requested location", async () => {
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0]?.searchParams.get("location[directory]")).toBe(other.directory)
|
||||
expect(requests[0]?.searchParams.get("location[workspace]")).toBe(other.workspaceID)
|
||||
expect(data.session.form.list("global", other)?.map((form) => form.id)).toEqual(["frm_other"])
|
||||
expect(data.session.form.list("global", { directory })?.map((form) => form.id)).toEqual(["frm_default"])
|
||||
expect(data.session.form.list("global", other)?.map((form) => form.id)).toEqual([Form.ID.make("frm_other")])
|
||||
expect(data.session.form.list("global", { directory })?.map((form) => form.id)).toEqual([
|
||||
Form.ID.make("frm_default"),
|
||||
])
|
||||
|
||||
data.session.form.invalidate("global", other)
|
||||
await data.session.form.sync("global", other)
|
||||
@@ -2460,7 +2503,10 @@ test("resyncs global forms only for the active location after reconnect", async
|
||||
const requests: URL[] = []
|
||||
const counts = new Map<string, number>()
|
||||
const home = { directory: process.cwd() }
|
||||
const other = { directory: "/tmp/opencode-other", workspaceID: "wrk_other" }
|
||||
const other = wire<{ directory: string; workspaceID: Workspace.ID }>({
|
||||
directory: "/tmp/opencode-other",
|
||||
workspaceID: "wrk_other",
|
||||
})
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname === "/api/location")
|
||||
return json({ ...home, project: { id: "proj_test", directory: home.directory } })
|
||||
@@ -2517,14 +2563,14 @@ test("resyncs global forms only for the active location after reconnect", async
|
||||
try {
|
||||
await wait(() => data.session.form.list("global", home)?.[0]?.id === "frm_default_1")
|
||||
await data.session.form.sync("global", other)
|
||||
expect(data.session.form.list("global", other)?.[0]?.id).toBe("frm_other_1")
|
||||
expect(data.session.form.list("global", other)?.[0]?.id).toBe(Form.ID.make("frm_other_1"))
|
||||
expect(requests).toHaveLength(2)
|
||||
requests.length = 0
|
||||
|
||||
events.disconnect()
|
||||
|
||||
await wait(() => data.session.form.list("global", home)?.[0]?.id === "frm_default_2", 4000)
|
||||
expect(data.session.form.list("global", other)?.[0]?.id).toBe("frm_other_1")
|
||||
expect(data.session.form.list("global", other)?.[0]?.id).toBe(Form.ID.make("frm_other_1"))
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(
|
||||
requests.map((url) => [
|
||||
@@ -2734,7 +2780,7 @@ test("settles pending tools when a live failure arrives", async () => {
|
||||
const assistant = sync.session.message.get("session-1", "msg_explicit_assistant_9")
|
||||
expect(assistant?.type).toBe("assistant")
|
||||
if (assistant?.type !== "assistant") return
|
||||
expect(assistant.id).toBe("msg_explicit_assistant_9")
|
||||
expect(assistant.id).toBe(SessionMessage.ID.make("msg_explicit_assistant_9"))
|
||||
const tool = assistant.content[0]
|
||||
expect(tool?.type).toBe("tool")
|
||||
if (tool?.type !== "tool") return
|
||||
@@ -2764,8 +2810,8 @@ test("settles pending tools when a live failure arrives", async () => {
|
||||
|
||||
test("renders admitted prompts immediately and tracks them until promoted", async () => {
|
||||
const events = createEventStream()
|
||||
const sessionID = "session-1"
|
||||
const messageID = "msg_user_1"
|
||||
const sessionID = Session.ID.make("session-1")
|
||||
const messageID = SessionMessage.ID.make("msg_user_1")
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname === `/api/session/${sessionID}/message`)
|
||||
return json({
|
||||
@@ -2997,9 +3043,9 @@ test("syncs direct child session info with a navigated root", async () => {
|
||||
const { data, app } = await mountData({ child: "root", sibling: "root", grandchild: "child" })
|
||||
try {
|
||||
await data.session.sync("root", { children: true })
|
||||
expect(data.session.get("root")?.id).toBe("root")
|
||||
expect(data.session.get("child")?.parentID).toBe("root")
|
||||
expect(data.session.get("sibling")?.parentID).toBe("root")
|
||||
expect(data.session.get("root")?.id).toBe(wire<Session.ID>("root"))
|
||||
expect(data.session.get("child")?.parentID).toBe(wire<Session.ID>("root"))
|
||||
expect(data.session.get("sibling")?.parentID).toBe(wire<Session.ID>("root"))
|
||||
expect(data.session.get("grandchild")).toBeUndefined()
|
||||
expect(data.session.family("root")).toEqual(["root", "child", "sibling"])
|
||||
} finally {
|
||||
@@ -3097,7 +3143,7 @@ test("stops at the last non-repeating ancestor on a parent cycle", async () => {
|
||||
|
||||
test("admits prompts optimistically and reconciles with the durable echo", async () => {
|
||||
const events = createEventStream()
|
||||
const sessionID = "session-1"
|
||||
const sessionID = Session.ID.make("session-1")
|
||||
let release!: (response: Response) => void
|
||||
const deferred = new Promise<Response>((resolve) => {
|
||||
release = resolve
|
||||
@@ -3199,14 +3245,14 @@ test("admits prompts optimistically and reconciles with the durable echo", async
|
||||
|
||||
test("hydrates durable pending prompts into the visible transcript", async () => {
|
||||
const sessionID = "session-1"
|
||||
const item = {
|
||||
const item = wire<SessionInboxInfo>({
|
||||
id: "msg_pending_1",
|
||||
sessionID,
|
||||
timeCreated: 5,
|
||||
type: "user" as const,
|
||||
payload: { text: "waiting" },
|
||||
delivery: "steer" as const,
|
||||
}
|
||||
})
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname === `/api/session/${sessionID}/inbox`) return json({ data: [item] })
|
||||
if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} })
|
||||
@@ -3252,7 +3298,7 @@ test("hydrates durable pending prompts into the visible transcript", async () =>
|
||||
test("keeps the row when the response lands before the echo", async () => {
|
||||
const events = createEventStream()
|
||||
const sessionID = "session-1"
|
||||
const messageID = "msg_early_1"
|
||||
const messageID = SessionMessage.ID.make("msg_early_1")
|
||||
const admission = {
|
||||
id: messageID,
|
||||
sessionID,
|
||||
@@ -3359,7 +3405,7 @@ test("rolls back an optimistic prompt the server rejected", async () => {
|
||||
test("a retry under the same client-minted ID cannot duplicate rows", async () => {
|
||||
const events = createEventStream()
|
||||
const sessionID = "session-1"
|
||||
const messageID = "msg_retry_1"
|
||||
const messageID = SessionMessage.ID.make("msg_retry_1")
|
||||
const admission = {
|
||||
id: messageID,
|
||||
sessionID,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { expect, test } from "bun:test"
|
||||
import type { SessionInfo } from "@opencode-ai/client"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { onMount } from "solid-js"
|
||||
import { DialogOpen, DialogOpenKey, loadDialogOpen } from "../../../src/component/dialog-open"
|
||||
@@ -20,9 +21,10 @@ import { emptyThemeSource } from "../../fixture/fixture"
|
||||
import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||
import { tmpdir } from "../../fixture/fixture"
|
||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
import { wire } from "../../fixture/wire"
|
||||
|
||||
test("selecting an unhydrated session preserves its location", async () => {
|
||||
const remote = { directory: "/tmp/opencode/remote", workspaceID: "ws_remote" }
|
||||
const remote = wire<SessionInfo["location"]>({ directory: "/tmp/opencode/remote", workspaceID: "ws_remote" })
|
||||
const fixture = await renderOpen((url) => {
|
||||
if (url.pathname !== "/api/session") return undefined
|
||||
return json({
|
||||
@@ -57,7 +59,7 @@ test("selecting an unhydrated session preserves its location", async () => {
|
||||
|
||||
test("finds and opens an exact session ID outside the recent list", async () => {
|
||||
const sessionID = "ses_04a7a3d82ffeIphUJgd3SnEqiv"
|
||||
const remote = { directory: "/tmp/opencode/archive", workspaceID: "ws_archive" }
|
||||
const remote = wire<SessionInfo["location"]>({ directory: "/tmp/opencode/archive", workspaceID: "ws_archive" })
|
||||
const fixture = await renderOpen((url) => {
|
||||
if (url.pathname === "/api/session") return json({ data: [], cursor: {} })
|
||||
if (url.pathname !== `/api/session/${sessionID}`) return undefined
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { expect, test } from "bun:test"
|
||||
import type { SessionInfo } from "@opencode-ai/client"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { onMount } from "solid-js"
|
||||
import { DialogSessionList } from "../../../src/component/dialog-session-list"
|
||||
@@ -22,6 +23,7 @@ import { createApi, createEventStream, createFetch, json } from "../../fixture/t
|
||||
import { emptyThemeSource, tmpdir } from "../../fixture/fixture"
|
||||
import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
import { wire } from "../../fixture/wire"
|
||||
|
||||
test("scopes sessions to the active session location", async () => {
|
||||
const active = "/tmp/opencode/project-b"
|
||||
@@ -60,15 +62,17 @@ test("scopes sessions to the active session location", async () => {
|
||||
const route = useRoute()
|
||||
storage = useStorage()
|
||||
onMount(() => {
|
||||
data.session.remember({
|
||||
id: "ses_active",
|
||||
projectID: "proj_b",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1, updated: 3 },
|
||||
title: "Active session",
|
||||
location: { directory: active },
|
||||
})
|
||||
data.session.remember(
|
||||
wire<SessionInfo>({
|
||||
id: "ses_active",
|
||||
projectID: "proj_b",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1, updated: 3 },
|
||||
title: "Active session",
|
||||
location: { directory: active },
|
||||
}),
|
||||
)
|
||||
route.navigate({ type: "session", sessionID: "ses_active" })
|
||||
dialog.replace(() => <DialogSessionList />)
|
||||
})
|
||||
|
||||
@@ -13,6 +13,7 @@ import { emptyThemeSource, tmpdir } from "../../fixture/fixture"
|
||||
import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
import { createApi, createEventStream, createFetch } from "../../fixture/tui-client"
|
||||
import { wire } from "../../fixture/wire"
|
||||
|
||||
async function mountForm(
|
||||
root: string,
|
||||
@@ -38,7 +39,7 @@ async function mountForm(
|
||||
events,
|
||||
)
|
||||
const config = createTuiResolvedConfig()
|
||||
const form = {
|
||||
const form = wire<FormWithLocation>({
|
||||
id: "frm_test",
|
||||
sessionID: "ses_test",
|
||||
title: "Authorization required",
|
||||
@@ -50,7 +51,7 @@ async function mountForm(
|
||||
title: "Authorize access",
|
||||
},
|
||||
],
|
||||
} satisfies FormWithLocation
|
||||
})
|
||||
const { FormPrompt } = await import("../../../src/routes/session/form")
|
||||
|
||||
function Harness() {
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client"
|
||||
import { findMessageBoundary, messageNavigationSlack } from "../../../src/routes/session/message-navigation"
|
||||
import { wire } from "../../fixture/wire"
|
||||
|
||||
const messages: SessionMessageInfo[] = [
|
||||
const messages = wire<SessionMessageInfo[]>([
|
||||
{ type: "user", id: "user-1", text: "First", time: { created: 0 } },
|
||||
assistant("assistant-1", "Response"),
|
||||
{ type: "user", id: "user-2", text: "Second", time: { created: 2 } },
|
||||
]
|
||||
])
|
||||
const children = [
|
||||
{ id: "user-1", y: 0 },
|
||||
{ id: "assistant-1", y: 20 },
|
||||
@@ -153,12 +154,12 @@ test("stops at the first and last message", () => {
|
||||
})
|
||||
|
||||
function assistant(id: string, text: string): SessionMessageAssistant {
|
||||
return {
|
||||
return wire<SessionMessageAssistant>({
|
||||
type: "assistant",
|
||||
id,
|
||||
agent: "build",
|
||||
model: { providerID: "test", id: "test" },
|
||||
content: [{ type: "text", text }],
|
||||
time: { created: 1, completed: 1 },
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client"
|
||||
import { cacheReuseDrop, messageBoundaryIDs, reduceSessionRows, turnDuration } from "../../../src/routes/session/rows"
|
||||
import { wire, type Wire } from "../../fixture/wire"
|
||||
|
||||
test("measures turn duration from the user prompt across assistant steps", () => {
|
||||
const first = assistant("assistant-1", [])
|
||||
first.time = { created: 8_000, completed: 11_000 }
|
||||
const final = assistant("assistant-2", [])
|
||||
final.time = { created: 27_000, completed: 30_000 }
|
||||
const messages: SessionMessageInfo[] = [
|
||||
const messages = wire<SessionMessageInfo[]>([
|
||||
{ type: "user", id: "user-1", text: "Question", time: { created: 1_000 } },
|
||||
first,
|
||||
final,
|
||||
]
|
||||
])
|
||||
|
||||
expect(turnDuration(final, messages)).toBe(29_000)
|
||||
})
|
||||
|
||||
test("filters OpenAI cache quantization from cache reuse drops", () => {
|
||||
const openai = { id: "gpt", providerID: "openai" }
|
||||
const openai = model("gpt", "openai")
|
||||
expect(cacheReuseDrop(undefined, { read: 10_000, model: openai })).toBeUndefined()
|
||||
expect(cacheReuseDrop({ read: 10_000, model: openai }, { read: 11_000, model: openai })).toBeUndefined()
|
||||
expect(cacheReuseDrop({ read: 10_000, model: openai }, { read: 8_977, model: openai })).toBe(1_023)
|
||||
@@ -28,34 +29,34 @@ test("filters OpenAI cache quantization from cache reuse drops", () => {
|
||||
})
|
||||
|
||||
test("compares cache reuse only for the same model", () => {
|
||||
const previous = { read: 10_000, model: { id: "claude", providerID: "anthropic" } }
|
||||
expect(cacheReuseDrop(previous, { read: 8_976, model: { id: "gpt", providerID: "openai" } })).toBeUndefined()
|
||||
expect(cacheReuseDrop(previous, { read: 8_976, model: { id: "claude", providerID: "anthropic" } })).toBe(1_024)
|
||||
const previous = { read: 10_000, model: model("claude", "anthropic") }
|
||||
expect(cacheReuseDrop(previous, { read: 8_976, model: model("gpt", "openai") })).toBeUndefined()
|
||||
expect(cacheReuseDrop(previous, { read: 8_976, model: model("claude", "anthropic") })).toBe(1_024)
|
||||
expect(
|
||||
cacheReuseDrop(
|
||||
{ read: 10_000, model: { id: "gpt", providerID: "openai", variant: "low" } },
|
||||
{ read: 8_976, model: { id: "gpt", providerID: "openai", variant: "high" } },
|
||||
{ read: 10_000, model: model("gpt", "openai", "low") },
|
||||
{ read: 8_976, model: model("gpt", "openai", "high") },
|
||||
),
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
test("carries model identity with the cross-turn cache baseline", () => {
|
||||
const first = assistant("assistant-1", [])
|
||||
first.model = { id: "claude", providerID: "anthropic" }
|
||||
first.model = model("claude", "anthropic")
|
||||
first.finish = "stop"
|
||||
first.tokens = { input: 1, output: 0, reasoning: 0, cache: { read: 10_000, write: 0 } }
|
||||
const second = assistant("assistant-2", [])
|
||||
second.model = { id: "gpt", providerID: "openai" }
|
||||
second.model = model("gpt", "openai")
|
||||
second.finish = "stop"
|
||||
second.tokens = { input: 1, output: 0, reasoning: 0, cache: { read: 8_976, write: 0 } }
|
||||
|
||||
const rows = reduceSessionRows(
|
||||
[
|
||||
wire<SessionMessageInfo[]>([
|
||||
{ type: "user", id: "user-1", text: "First", time: { created: 0 } },
|
||||
first,
|
||||
{ type: "user", id: "user-2", text: "Second", time: { created: 2 } },
|
||||
second,
|
||||
],
|
||||
]),
|
||||
new Set(),
|
||||
true,
|
||||
).filter((row) => row.type === "turn-usage")
|
||||
@@ -65,7 +66,7 @@ test("carries model identity with the cross-turn cache baseline", () => {
|
||||
{
|
||||
type: "turn-usage",
|
||||
messageIDs: ["assistant-2"],
|
||||
previousCache: { read: 10_000, model: { id: "claude", providerID: "anthropic" } },
|
||||
previousCache: { read: 10_000, model: model("claude", "anthropic") },
|
||||
},
|
||||
])
|
||||
})
|
||||
@@ -79,7 +80,7 @@ test("resets the cross-turn cache baseline after compaction", () => {
|
||||
second.tokens = { input: 1, output: 0, reasoning: 0, cache: { read: 13_824, write: 0 } }
|
||||
|
||||
const rows = reduceSessionRows(
|
||||
[
|
||||
wire<SessionMessageInfo[]>([
|
||||
first,
|
||||
{
|
||||
type: "compaction",
|
||||
@@ -91,7 +92,7 @@ test("resets the cross-turn cache baseline after compaction", () => {
|
||||
time: { created: 2 },
|
||||
},
|
||||
second,
|
||||
],
|
||||
]),
|
||||
new Set(),
|
||||
true,
|
||||
).filter((row) => row.type === "turn-usage")
|
||||
@@ -103,21 +104,23 @@ test("resets the cross-turn cache baseline after compaction", () => {
|
||||
})
|
||||
|
||||
test("assigns assistant boundaries to the first rendered row instead of the first text row", () => {
|
||||
const messages: SessionMessageInfo[] = [
|
||||
const messages = wire<SessionMessageInfo[]>([
|
||||
{ type: "user", id: "user-1", text: "Question", time: { created: 0 } },
|
||||
assistant("assistant-1", [
|
||||
{ type: "reasoning", text: "Thinking" },
|
||||
{ type: "text", text: "First" },
|
||||
{ type: "text", text: "Second" },
|
||||
]),
|
||||
]
|
||||
])
|
||||
const rows = reduceSessionRows(messages)
|
||||
|
||||
expect(messageBoundaryIDs(rows, messages)).toEqual(["user-1", "assistant-1", undefined, undefined])
|
||||
expect(messageBoundaryIDs(rows, messages)).toEqual(
|
||||
wire<ReturnType<typeof messageBoundaryIDs>>(["user-1", "assistant-1", undefined, undefined]),
|
||||
)
|
||||
})
|
||||
|
||||
test("groups exploration parts across assistant messages until a delimiter", () => {
|
||||
const messages: SessionMessageInfo[] = [
|
||||
const messages = wire<SessionMessageInfo[]>([
|
||||
{ type: "user", id: "user-1", text: "Explore", time: { created: 0 } },
|
||||
assistant("assistant-1", [
|
||||
{ type: "text", text: "Looking" },
|
||||
@@ -128,7 +131,7 @@ test("groups exploration parts across assistant messages until a delimiter", ()
|
||||
{ type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 5 } },
|
||||
{ type: "text", text: "Done" },
|
||||
]),
|
||||
]
|
||||
])
|
||||
|
||||
expect(reduceSessionRows(messages)).toEqual([
|
||||
{ type: "message", messageID: "user-1" },
|
||||
@@ -149,13 +152,13 @@ test("groups exploration parts across assistant messages until a delimiter", ()
|
||||
})
|
||||
|
||||
test("keeps non-exploration tools as individual part rows", () => {
|
||||
const messages: SessionMessageInfo[] = [
|
||||
const messages = wire<SessionMessageInfo[]>([
|
||||
assistant("assistant-1", [
|
||||
{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 1 } },
|
||||
{ type: "tool", id: "reasoning:0", name: "bash", state: pending(), time: { created: 2 } },
|
||||
{ type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 3 } },
|
||||
]),
|
||||
]
|
||||
])
|
||||
|
||||
expect(reduceSessionRows(messages)).toEqual([
|
||||
{
|
||||
@@ -177,14 +180,14 @@ test("keeps non-exploration tools as individual part rows", () => {
|
||||
})
|
||||
|
||||
test("assigns stable kind ordinals within an assistant message", () => {
|
||||
const messages: SessionMessageInfo[] = [
|
||||
const messages = wire<SessionMessageInfo[]>([
|
||||
assistant("assistant-1", [
|
||||
{ type: "text", text: "First" },
|
||||
{ type: "reasoning", text: "Think" },
|
||||
{ type: "text", text: "Second" },
|
||||
{ type: "reasoning", text: "Check" },
|
||||
]),
|
||||
]
|
||||
])
|
||||
|
||||
expect(reduceSessionRows(messages)).toEqual([
|
||||
{ type: "part", ref: { messageID: "assistant-1", partID: "text:0" } },
|
||||
@@ -205,14 +208,14 @@ test("assigns stable kind ordinals within an assistant message", () => {
|
||||
})
|
||||
|
||||
test("groups adjacent reasoning parts until a visible boundary", () => {
|
||||
const messages: SessionMessageInfo[] = [
|
||||
const messages = wire<SessionMessageInfo[]>([
|
||||
assistant("assistant-1", [
|
||||
{ type: "reasoning", text: "First" },
|
||||
{ type: "reasoning", text: "Second" },
|
||||
{ type: "text", text: "Visible" },
|
||||
{ type: "reasoning", text: "Third" },
|
||||
]),
|
||||
]
|
||||
])
|
||||
|
||||
expect(reduceSessionRows(messages)).toEqual([
|
||||
{
|
||||
@@ -235,7 +238,7 @@ test("groups adjacent reasoning parts until a visible boundary", () => {
|
||||
})
|
||||
|
||||
test("groups across empty assistant reasoning parts", () => {
|
||||
const messages: SessionMessageInfo[] = [
|
||||
const messages = wire<SessionMessageInfo[]>([
|
||||
assistant("assistant-1", [
|
||||
{ type: "reasoning", text: "Looking" },
|
||||
{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 2 } },
|
||||
@@ -244,7 +247,7 @@ test("groups across empty assistant reasoning parts", () => {
|
||||
{ type: "reasoning", text: "" },
|
||||
{ type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 3 } },
|
||||
]),
|
||||
]
|
||||
])
|
||||
|
||||
expect(reduceSessionRows(messages)).toEqual([
|
||||
{
|
||||
@@ -271,11 +274,11 @@ test("completes exploration groups when another row follows", () => {
|
||||
{ type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 3 } },
|
||||
])
|
||||
finished.finish = "stop"
|
||||
const messages: SessionMessageInfo[] = [
|
||||
const messages = wire<SessionMessageInfo[]>([
|
||||
assistant("assistant-1", [{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 1 } }]),
|
||||
{ type: "user", id: "user-1", text: "Continue", time: { created: 2 } },
|
||||
finished,
|
||||
]
|
||||
])
|
||||
|
||||
expect(reduceSessionRows(messages)).toEqual([
|
||||
{
|
||||
@@ -298,7 +301,7 @@ test("completes exploration groups when another row follows", () => {
|
||||
})
|
||||
|
||||
test("hides synthetic messages without descriptions", () => {
|
||||
const messages: SessionMessageInfo[] = [
|
||||
const messages = wire<SessionMessageInfo[]>([
|
||||
assistant("assistant-1", [{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 1 } }]),
|
||||
{
|
||||
type: "synthetic",
|
||||
@@ -307,7 +310,7 @@ test("hides synthetic messages without descriptions", () => {
|
||||
time: { created: 2 },
|
||||
},
|
||||
assistant("assistant-2", [{ type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 3 } }]),
|
||||
]
|
||||
])
|
||||
|
||||
expect(reduceSessionRows(messages)).toEqual([
|
||||
{
|
||||
@@ -324,7 +327,7 @@ test("hides synthetic messages without descriptions", () => {
|
||||
})
|
||||
|
||||
test("renders synthetic messages with descriptions", () => {
|
||||
const messages: SessionMessageInfo[] = [
|
||||
const messages = wire<SessionMessageInfo[]>([
|
||||
assistant("assistant-1", [{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 1 } }]),
|
||||
{
|
||||
type: "synthetic",
|
||||
@@ -334,7 +337,7 @@ test("renders synthetic messages with descriptions", () => {
|
||||
time: { created: 2 },
|
||||
},
|
||||
assistant("assistant-2", [{ type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 3 } }]),
|
||||
]
|
||||
])
|
||||
|
||||
expect(reduceSessionRows(messages)).toEqual([
|
||||
{
|
||||
@@ -367,13 +370,14 @@ test("renders a footer for a pre-output retry assistant after replay", () => {
|
||||
})
|
||||
|
||||
test("places a running compaction barrier before every queued user message", () => {
|
||||
const queued = (id: string, text: string, created: number): SessionMessageInfo => ({
|
||||
type: "user",
|
||||
id,
|
||||
text,
|
||||
time: { created },
|
||||
})
|
||||
const messages: SessionMessageInfo[] = [
|
||||
const queued = (id: string, text: string, created: number) =>
|
||||
wire<SessionMessageInfo>({
|
||||
type: "user",
|
||||
id,
|
||||
text,
|
||||
time: { created },
|
||||
})
|
||||
const messages = wire<SessionMessageInfo[]>([
|
||||
queued("user-before", "Before", 1),
|
||||
{
|
||||
type: "compaction",
|
||||
@@ -385,7 +389,7 @@ test("places a running compaction barrier before every queued user message", ()
|
||||
time: { created: 2 },
|
||||
},
|
||||
queued("user-after", "After", 3),
|
||||
]
|
||||
])
|
||||
|
||||
expect(reduceSessionRows(messages, new Set(["user-before", "user-after"]))).toEqual([
|
||||
{ type: "message", messageID: "compaction" },
|
||||
@@ -394,15 +398,19 @@ test("places a running compaction barrier before every queued user message", ()
|
||||
])
|
||||
})
|
||||
|
||||
function assistant(id: string, content: SessionMessageAssistant["content"]): SessionMessageAssistant {
|
||||
return {
|
||||
function assistant(id: string, content: Wire<SessionMessageAssistant["content"]>): SessionMessageAssistant {
|
||||
return wire<SessionMessageAssistant>({
|
||||
type: "assistant",
|
||||
id,
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
model: model("model", "provider"),
|
||||
content,
|
||||
time: { created: 1 },
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function model(id: string, providerID: string, variant?: string): SessionMessageAssistant["model"] {
|
||||
return wire<SessionMessageAssistant["model"]>({ id, providerID, ...(variant ? { variant } : {}) })
|
||||
}
|
||||
|
||||
function pending() {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useEvent } from "../../../src/context/event"
|
||||
import { createApi, createEventStream, createFetch } from "../../fixture/tui-client"
|
||||
import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||
import type { LogLevel, LogSink } from "../../../src/context/log"
|
||||
import { wire, type Wire } from "../../fixture/wire"
|
||||
|
||||
const projectID = "proj_test"
|
||||
|
||||
@@ -20,35 +21,35 @@ async function wait(fn: () => boolean, timeout = 2000) {
|
||||
}
|
||||
|
||||
function event(
|
||||
payload: OpenCodeEvent,
|
||||
payload: Wire<OpenCodeEvent>,
|
||||
input: { directory: string; project?: string; workspace?: string },
|
||||
): OpenCodeEvent {
|
||||
return {
|
||||
return wire<OpenCodeEvent>({
|
||||
...payload,
|
||||
location: { directory: input.directory, workspaceID: input.workspace },
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function vcs(branch: string): OpenCodeEvent {
|
||||
return {
|
||||
return wire<OpenCodeEvent>({
|
||||
id: `evt_vcs_${branch}`,
|
||||
created: 0,
|
||||
type: "vcs.branch.updated",
|
||||
data: {
|
||||
branch,
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function update(version: string): OpenCodeEvent {
|
||||
return {
|
||||
return wire<OpenCodeEvent>({
|
||||
id: `evt_update_${version}`,
|
||||
created: 0,
|
||||
type: "installation.update-available",
|
||||
data: {
|
||||
version,
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function mount(reconnect?: (signal: AbortSignal) => Promise<{ api: OpenCodeClient }>, log?: LogSink) {
|
||||
|
||||
@@ -17,6 +17,7 @@ import { createApi, createEventStream, createFetch, directory, json } from "../f
|
||||
import { TestTuiContexts } from "../fixture/tui-environment"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { createTuiResolvedConfig } from "../fixture/tui-runtime"
|
||||
import { wire, type Wire } from "../fixture/wire"
|
||||
|
||||
async function wait(fn: () => boolean | Promise<boolean>, timeout = 2_000, label = "condition") {
|
||||
const start = Date.now()
|
||||
@@ -141,7 +142,7 @@ async function renderSessionTabs(
|
||||
locations,
|
||||
vcsLocations,
|
||||
state,
|
||||
emit: (event: OpenCodeEvent) => events.emit({ ...event, location: { directory } }),
|
||||
emit: (event: Wire<OpenCodeEvent>) => events.emit(wire<OpenCodeEvent>({ ...event, location: { directory } })),
|
||||
focus: () => app.renderer.emit("focus"),
|
||||
blur: () => app.renderer.emit("blur"),
|
||||
flush: () => storage.flush(),
|
||||
@@ -153,7 +154,7 @@ async function renderSessionTabs(
|
||||
}
|
||||
}
|
||||
|
||||
const executionSucceeded = (sessionID: string): OpenCodeEvent => ({
|
||||
const executionSucceeded = (sessionID: string): Wire<OpenCodeEvent> => ({
|
||||
id: `evt_done_${sessionID}`,
|
||||
created: Date.now(),
|
||||
type: "session.execution.succeeded",
|
||||
@@ -347,7 +348,7 @@ test("concurrent TUIs do not alternate shared tab titles from divergent session
|
||||
|
||||
test("user prompt admissions pulse an already-busy background tab", async () => {
|
||||
const setup = await renderSessionTabs("background")
|
||||
const admitted = (sessionID: string, inboxID: string): OpenCodeEvent => ({
|
||||
const admitted = (sessionID: string, inboxID: string): Wire<OpenCodeEvent> => ({
|
||||
id: `evt_${inboxID}`,
|
||||
created: Date.now(),
|
||||
type: "session.inbox.enqueued",
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Brand } from "effect"
|
||||
|
||||
export type Wire<T> =
|
||||
T extends Brand.Brand<string>
|
||||
? Brand.Brand.Unbranded<T>
|
||||
: T extends ReadonlyArray<infer Item>
|
||||
? Wire<Item>[]
|
||||
: T extends object
|
||||
? { [Key in keyof T]: Wire<T[Key]> }
|
||||
: T
|
||||
|
||||
export function wire<T>(value: Wire<T>): T {
|
||||
return value as T
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
import { spyOn } from "bun:test"
|
||||
import type { LocationRef, ModelListOutput, OpenCodeClient, ProviderListOutput } from "@opencode-ai/client/promise"
|
||||
import { wire } from "../../fixture/wire"
|
||||
|
||||
export function catalogProvider(id: string, name: string): ProviderListOutput["data"][number] {
|
||||
return {
|
||||
return wire<ProviderListOutput["data"][number]>({
|
||||
id,
|
||||
name,
|
||||
activation: "auto",
|
||||
package: "",
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function catalogModel(input: {
|
||||
@@ -18,7 +19,7 @@ export function catalogModel(input: {
|
||||
context?: number
|
||||
variants?: string[]
|
||||
}): ModelListOutput["data"][number] {
|
||||
return {
|
||||
return wire<ModelListOutput["data"][number]>({
|
||||
id: input.id,
|
||||
modelID: input.modelID ?? input.id,
|
||||
providerID: input.providerID,
|
||||
@@ -34,7 +35,7 @@ export function catalogModel(input: {
|
||||
status: "active",
|
||||
enabled: true,
|
||||
limit: { context: input.context ?? 128_000, output: 8_192 },
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function stubCatalogLists(
|
||||
|
||||
@@ -39,6 +39,7 @@ import type {
|
||||
import { selectedCommand } from "../../src/mini/footer.prompt"
|
||||
import { RejectField } from "../../src/mini/footer.permission"
|
||||
import { createTuiResolvedConfig } from "../fixture/tui-runtime"
|
||||
import { wire } from "../fixture/wire"
|
||||
|
||||
const tuiConfig = createTuiResolvedConfig()
|
||||
|
||||
@@ -228,7 +229,7 @@ test("direct footer shows the default model without the fallback agent", async (
|
||||
})
|
||||
|
||||
test("direct footer preserves a partial multi-field form draft across permission preemption", async () => {
|
||||
const request: FormInfo = {
|
||||
const request = wire<FormInfo>({
|
||||
id: "frm_preempted",
|
||||
sessionID: "ses_child",
|
||||
title: "Deployment",
|
||||
@@ -236,7 +237,7 @@ test("direct footer preserves a partial multi-field form draft across permission
|
||||
{ key: "service", type: "string", title: "Service", required: true },
|
||||
{ key: "notes", type: "string", title: "Notes", required: true },
|
||||
],
|
||||
}
|
||||
})
|
||||
const app = await renderFooter({
|
||||
height: 16,
|
||||
view: { type: "form", request },
|
||||
@@ -250,15 +251,17 @@ test("direct footer preserves a partial multi-field form draft across permission
|
||||
"keep this draft".split("").forEach((key) => app.mockInput.pressKey(key))
|
||||
expect(app.renderer.currentFocusedEditor?.plainText).toBe("keep this draft")
|
||||
|
||||
app.setView({
|
||||
type: "permission",
|
||||
request: {
|
||||
id: "per_preempting",
|
||||
sessionID: "ses_child",
|
||||
action: "read",
|
||||
resources: ["src/index.ts"],
|
||||
},
|
||||
})
|
||||
app.setView(
|
||||
wire<FooterView>({
|
||||
type: "permission",
|
||||
request: {
|
||||
id: "per_preempting",
|
||||
sessionID: "ses_child",
|
||||
action: "read",
|
||||
resources: ["src/index.ts"],
|
||||
},
|
||||
}),
|
||||
)
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame()).toContain("Permission required")
|
||||
|
||||
|
||||
@@ -13,9 +13,11 @@ import {
|
||||
formUnsupported,
|
||||
formValidate,
|
||||
} from "../../src/mini/form.shared"
|
||||
import type { FormReply, MiniFormRequest } from "../../src/mini/types"
|
||||
import { wire } from "../fixture/wire"
|
||||
|
||||
function request(fields: FormField[]): FormInfo {
|
||||
return { id: "frm_1", sessionID: "ses_1", title: "Input", fields: fields as FormInfo["fields"] }
|
||||
return wire<FormInfo>({ id: "frm_1", sessionID: "ses_1", title: "Input", fields })
|
||||
}
|
||||
|
||||
describe("Mini form state", () => {
|
||||
@@ -39,12 +41,16 @@ describe("Mini form state", () => {
|
||||
|
||||
const answer = { choice: "fast", count: 1.5, whole: 2, enabled: false, tags: ["custom"], external: true }
|
||||
expect(formAnswer(form, state)).toEqual(answer)
|
||||
expect(formReply({ ...form, location: { directory: "/tmp", workspaceID: "wrk_1" } }, state)).toEqual({
|
||||
sessionID: "ses_1",
|
||||
formID: "frm_1",
|
||||
answer,
|
||||
location: { directory: "/tmp", workspaceID: "wrk_1" },
|
||||
})
|
||||
expect(
|
||||
formReply(wire<MiniFormRequest>({ ...form, location: { directory: "/tmp", workspaceID: "wrk_1" } }), state),
|
||||
).toEqual(
|
||||
wire<FormReply>({
|
||||
sessionID: "ses_1",
|
||||
formID: "frm_1",
|
||||
answer,
|
||||
location: { directory: "/tmp", workspaceID: "wrk_1" },
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("rejects invalid and deliberately unsupported shapes", () => {
|
||||
|
||||
@@ -9,10 +9,11 @@ import {
|
||||
permissionRun,
|
||||
} from "../../src/mini/permission.shared"
|
||||
import type { MiniPermissionRequest } from "../../src/mini/types"
|
||||
import { wire } from "../fixture/wire"
|
||||
import { canonicalToolPart } from "./fixture/tool-part"
|
||||
|
||||
function req(input: Partial<MiniPermissionRequest> = {}): MiniPermissionRequest {
|
||||
return {
|
||||
return wire<MiniPermissionRequest>({
|
||||
id: "perm-1",
|
||||
sessionID: "session-1",
|
||||
action: "read",
|
||||
@@ -20,7 +21,7 @@ function req(input: Partial<MiniPermissionRequest> = {}): MiniPermissionRequest
|
||||
metadata: {},
|
||||
save: [],
|
||||
...input,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function body() {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
|
||||
import { OpenCode } from "@opencode-ai/client/promise"
|
||||
import { runInteractiveDeferredMode } from "../../src/mini/runtime"
|
||||
import { OpenCode, type LocationRef } from "@opencode-ai/client/promise"
|
||||
import { runInteractiveDeferredMode, type RunDeferredInput } from "../../src/mini/runtime"
|
||||
import type { LifecycleInput } from "../../src/mini/runtime.lifecycle"
|
||||
import type { FooterEvent, MiniHost } from "../../src/mini/types"
|
||||
import type { FooterEvent, FormReply, MiniHost } from "../../src/mini/types"
|
||||
import { wire, type Wire } from "../fixture/wire"
|
||||
import { catalogModel, catalogProvider, stubCatalogLists } from "./fixture/catalog"
|
||||
import { createFooterApiFixture } from "./fixture/footer-api"
|
||||
import { createTuiResolvedConfig } from "../fixture/tui-runtime"
|
||||
@@ -19,6 +20,10 @@ function ok<T>(data: T) {
|
||||
return Promise.resolve(data)
|
||||
}
|
||||
|
||||
function resolvedSession(value: Wire<Awaited<ReturnType<RunDeferredInput["target"]>>>) {
|
||||
return wire<Awaited<ReturnType<RunDeferredInput["target"]>>>(value)
|
||||
}
|
||||
|
||||
function host(): MiniHost {
|
||||
return {
|
||||
terminal: { stdin: process.stdin },
|
||||
@@ -90,14 +95,15 @@ describe("run interactive runtime", () => {
|
||||
host: host(),
|
||||
sdk,
|
||||
directory: "/tmp",
|
||||
target: async () => ({
|
||||
sessionID: "ses_root",
|
||||
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } },
|
||||
agent: "build",
|
||||
model: undefined,
|
||||
variant: undefined,
|
||||
resume: false,
|
||||
}),
|
||||
target: async () =>
|
||||
resolvedSession({
|
||||
sessionID: "ses_root",
|
||||
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } },
|
||||
agent: "build",
|
||||
model: undefined,
|
||||
variant: undefined,
|
||||
resume: false,
|
||||
}),
|
||||
agent: "build",
|
||||
model: undefined,
|
||||
variant: undefined,
|
||||
@@ -144,10 +150,12 @@ describe("run interactive runtime", () => {
|
||||
expect(events.some((event) => event.type === "model")).toBe(false)
|
||||
await refreshCatalog?.()
|
||||
expect(defaultModel).toHaveBeenCalledTimes(1)
|
||||
selected.resolve({
|
||||
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } },
|
||||
data: model,
|
||||
})
|
||||
selected.resolve(
|
||||
wire<Awaited<ReturnType<typeof sdk.model.default>>>({
|
||||
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } },
|
||||
data: model,
|
||||
}),
|
||||
)
|
||||
await defaultModelReloaded.promise
|
||||
await modelShown.promise
|
||||
expect(events).toContainEqual({
|
||||
@@ -179,14 +187,15 @@ describe("run interactive runtime", () => {
|
||||
host: host(),
|
||||
sdk,
|
||||
directory: "/tmp",
|
||||
target: async () => ({
|
||||
sessionID: "ses_root",
|
||||
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } },
|
||||
agent: "build",
|
||||
model: { providerID: "test", modelID: "model" },
|
||||
variant: undefined,
|
||||
resume: false,
|
||||
}),
|
||||
target: async () =>
|
||||
resolvedSession({
|
||||
sessionID: "ses_root",
|
||||
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } },
|
||||
agent: "build",
|
||||
model: { providerID: "test", modelID: "model" },
|
||||
variant: undefined,
|
||||
resume: false,
|
||||
}),
|
||||
agent: "build",
|
||||
model: { providerID: "test", modelID: "model" },
|
||||
variant: undefined,
|
||||
@@ -225,12 +234,14 @@ describe("run interactive runtime", () => {
|
||||
)
|
||||
await streamStarted.promise
|
||||
|
||||
await lifecycle.onFormReply({
|
||||
sessionID: "global",
|
||||
formID: "frm_global",
|
||||
answer: { value: "yes" },
|
||||
location: { directory: "/remote work", workspaceID: "wrk_1" },
|
||||
})
|
||||
await lifecycle.onFormReply(
|
||||
wire<FormReply>({
|
||||
sessionID: "global",
|
||||
formID: "frm_global",
|
||||
answer: { value: "yes" },
|
||||
location: { directory: "/remote work", workspaceID: "wrk_1" },
|
||||
}),
|
||||
)
|
||||
expect(reply).toHaveBeenCalledWith(
|
||||
{
|
||||
sessionID: "global",
|
||||
@@ -274,7 +285,7 @@ describe("run interactive runtime", () => {
|
||||
target: async () => {
|
||||
resolved++
|
||||
api.close()
|
||||
return {
|
||||
return resolvedSession({
|
||||
sessionID: "ses-deferred",
|
||||
sessionTitle: "Deferred",
|
||||
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } },
|
||||
@@ -282,7 +293,7 @@ describe("run interactive runtime", () => {
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
variant: undefined,
|
||||
resume: false,
|
||||
}
|
||||
})
|
||||
},
|
||||
agent: "build",
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
@@ -362,15 +373,16 @@ describe("run interactive runtime", () => {
|
||||
host: host(),
|
||||
sdk,
|
||||
directory: "/tmp",
|
||||
target: async () => ({
|
||||
sessionID: "ses-resume",
|
||||
sessionTitle: "Resume",
|
||||
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } },
|
||||
agent: "review",
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
variant: "high",
|
||||
resume: true,
|
||||
}),
|
||||
target: async () =>
|
||||
resolvedSession({
|
||||
sessionID: "ses-resume",
|
||||
sessionTitle: "Resume",
|
||||
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } },
|
||||
agent: "review",
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
variant: "high",
|
||||
resume: true,
|
||||
}),
|
||||
agent: "build",
|
||||
model: undefined,
|
||||
variant: undefined,
|
||||
@@ -445,15 +457,16 @@ describe("run interactive runtime", () => {
|
||||
host: host(),
|
||||
sdk,
|
||||
directory: "/tmp",
|
||||
target: async () => ({
|
||||
sessionID: "ses-resume-abort",
|
||||
sessionTitle: "Cached title",
|
||||
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } },
|
||||
agent: "build",
|
||||
model: undefined,
|
||||
variant: undefined,
|
||||
resume: true,
|
||||
}),
|
||||
target: async () =>
|
||||
resolvedSession({
|
||||
sessionID: "ses-resume-abort",
|
||||
sessionTitle: "Cached title",
|
||||
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } },
|
||||
agent: "build",
|
||||
model: undefined,
|
||||
variant: undefined,
|
||||
resume: true,
|
||||
}),
|
||||
agent: "build",
|
||||
model: undefined,
|
||||
variant: undefined,
|
||||
@@ -502,7 +515,7 @@ describe("run interactive runtime", () => {
|
||||
let runtimeConfig: LifecycleInput["tuiConfig"] | undefined
|
||||
const tuiConfig = createTuiResolvedConfig({ keybinds: { "variant.cycle": "ctrl+g" } })
|
||||
const catalogs = stubCatalogLists(sdk, {
|
||||
location: { directory: "/session", workspaceID: "work-1" },
|
||||
location: wire<LocationRef>({ directory: "/session", workspaceID: "work-1" }),
|
||||
})
|
||||
const fileFind = spyOn(sdk.file, "find").mockResolvedValue({
|
||||
location: {
|
||||
@@ -520,7 +533,7 @@ describe("run interactive runtime", () => {
|
||||
directory: "/launch",
|
||||
target: async () => {
|
||||
targets++
|
||||
return {
|
||||
return resolvedSession({
|
||||
sessionID: "ses-target",
|
||||
location: {
|
||||
directory: "/session",
|
||||
@@ -531,7 +544,7 @@ describe("run interactive runtime", () => {
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
variant: "high",
|
||||
resume: false,
|
||||
}
|
||||
})
|
||||
},
|
||||
agent: undefined,
|
||||
model: undefined,
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type RunSession,
|
||||
type SessionMessages,
|
||||
} from "../../src/mini/session.shared"
|
||||
import { wire } from "../fixture/wire"
|
||||
|
||||
const model = {
|
||||
providerID: "openai",
|
||||
@@ -19,13 +20,13 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
function userMessage(id: string, text: string, input: Partial<SessionMessageUser> = {}): SessionMessageUser {
|
||||
return {
|
||||
return wire<SessionMessageUser>({
|
||||
id,
|
||||
type: "user",
|
||||
text,
|
||||
time: { created: 1 },
|
||||
...input,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe("run session shared", () => {
|
||||
@@ -158,39 +159,43 @@ describe("run session shared", () => {
|
||||
test("restores current prompt history from stored text and file references", async () => {
|
||||
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
|
||||
spyOn(client.message, "list").mockImplementation(() =>
|
||||
Promise.resolve({
|
||||
data: [
|
||||
{
|
||||
id: "msg_prompt",
|
||||
type: "user",
|
||||
text: "Review @note.ts",
|
||||
files: [
|
||||
{
|
||||
data: "",
|
||||
mime: "text/plain",
|
||||
name: "note.ts",
|
||||
source: { type: "uri", uri: "file:///tmp/note.ts" },
|
||||
mention: { start: 7, end: 15, text: "@note.ts" },
|
||||
},
|
||||
],
|
||||
agents: [],
|
||||
time: { created: 1 },
|
||||
},
|
||||
],
|
||||
cursor: {},
|
||||
}),
|
||||
Promise.resolve(
|
||||
wire<Awaited<ReturnType<typeof client.message.list>>>({
|
||||
data: [
|
||||
{
|
||||
id: "msg_prompt",
|
||||
type: "user",
|
||||
text: "Review @note.ts",
|
||||
files: [
|
||||
{
|
||||
data: "",
|
||||
mime: "text/plain",
|
||||
name: "note.ts",
|
||||
source: { type: "uri", uri: "file:///tmp/note.ts" },
|
||||
mention: { start: 7, end: 15, text: "@note.ts" },
|
||||
},
|
||||
],
|
||||
agents: [],
|
||||
time: { created: 1 },
|
||||
},
|
||||
],
|
||||
cursor: {},
|
||||
}),
|
||||
),
|
||||
)
|
||||
spyOn(client.session, "get").mockImplementation(() =>
|
||||
Promise.resolve({
|
||||
id: "ses_1",
|
||||
title: "Session",
|
||||
projectID: "proj_1",
|
||||
location: { directory: "/tmp" },
|
||||
time: { created: 1, updated: 1 },
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
model: { providerID: "openai", id: "gpt-5", variant: "high" },
|
||||
}),
|
||||
Promise.resolve(
|
||||
wire<Awaited<ReturnType<typeof client.session.get>>>({
|
||||
id: "ses_1",
|
||||
title: "Session",
|
||||
projectID: "proj_1",
|
||||
location: { directory: "/tmp" },
|
||||
time: { created: 1, updated: 1 },
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
model: { providerID: "openai", id: "gpt-5", variant: "high" },
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const controller = new AbortController()
|
||||
|
||||
@@ -12,10 +12,11 @@ import {
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { createSessionTransport } from "../../src/mini/stream-v2.transport"
|
||||
import { entryBody } from "../../src/mini/entry.body"
|
||||
import type { StreamCommit } from "../../src/mini/types"
|
||||
import type { FooterEvent, StreamCommit } from "../../src/mini/types"
|
||||
import { createFooterApiFixture } from "./fixture/footer-api"
|
||||
import { canonicalToolPart } from "./fixture/tool-part"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { wire, type Wire } from "../fixture/wire"
|
||||
|
||||
type RunV2Event = EventSubscribeOutput
|
||||
|
||||
@@ -37,8 +38,8 @@ function feed() {
|
||||
})()
|
||||
return {
|
||||
stream,
|
||||
push(value: RunV2Event) {
|
||||
values.push(value)
|
||||
push(value: Wire<RunV2Event>) {
|
||||
values.push(wire<RunV2Event>(value))
|
||||
wake?.()
|
||||
wake = undefined
|
||||
},
|
||||
@@ -63,7 +64,7 @@ function defer<T = void>() {
|
||||
}
|
||||
|
||||
function connected(id = "evt_connected") {
|
||||
return { id, type: "server.connected", data: {} } satisfies RunV2Event
|
||||
return wire<RunV2Event>({ id, type: "server.connected", data: {} })
|
||||
}
|
||||
|
||||
function durable(sessionID: string, seq?: number): { aggregateID: string; seq: number; version: 1 }
|
||||
@@ -77,19 +78,17 @@ function durable(sessionID: string, seq = 0, version: 1 | 2 = 1) {
|
||||
}
|
||||
|
||||
function promptAdmission(input: Parameters<OpenCodeClient["session"]["prompt"]>[0], sessionID = "ses_1") {
|
||||
return {
|
||||
return wire<Awaited<ReturnType<OpenCodeClient["session"]["prompt"]>>>({
|
||||
id: input.id ?? "msg_prompt",
|
||||
sessionID,
|
||||
type: "user" as const,
|
||||
payload: {
|
||||
text: input.text,
|
||||
files: input.files,
|
||||
agents: input.agents,
|
||||
metadata: input.metadata,
|
||||
},
|
||||
delivery: input.delivery ?? ("steer" as const),
|
||||
timeCreated: 2,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function footer() {
|
||||
@@ -98,6 +97,14 @@ function footer() {
|
||||
|
||||
type SessionMessages = MessageListOutput["data"]
|
||||
|
||||
function messageList(data: Wire<SessionMessages>) {
|
||||
return ok(wire<MessageListOutput>({ data, cursor: {} }))
|
||||
}
|
||||
|
||||
function location(directory: string, workspaceID: string) {
|
||||
return wire<NonNullable<Parameters<typeof createSessionTransport>[0]["location"]>>({ directory, workspaceID })
|
||||
}
|
||||
|
||||
function compaction(status: "running" | "completed", summary: string): SessionMessages[number] {
|
||||
const message = {
|
||||
id: "msg_compaction",
|
||||
@@ -107,12 +114,12 @@ function compaction(status: "running" | "completed", summary: string): SessionMe
|
||||
recent: "",
|
||||
time: { created: 1 },
|
||||
}
|
||||
if (status === "running") return { ...message, status }
|
||||
return { ...message, status }
|
||||
if (status === "running") return wire<SessionMessages[number]>({ ...message, status })
|
||||
return wire<SessionMessages[number]>({ ...message, status })
|
||||
}
|
||||
|
||||
function form(id: string, sessionID: string, title = id): FormInfo {
|
||||
return {
|
||||
return wire<FormInfo>({
|
||||
id,
|
||||
sessionID,
|
||||
title,
|
||||
@@ -124,7 +131,7 @@ function form(id: string, sessionID: string, title = id): FormInfo {
|
||||
custom: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function eventForm(info: FormInfo): Extract<RunV2Event, { type: "form.created" }>["data"]["form"] {
|
||||
@@ -134,55 +141,67 @@ function eventForm(info: FormInfo): Extract<RunV2Event, { type: "form.created" }
|
||||
function sdk(input: {
|
||||
streams: ReturnType<typeof feed>[]
|
||||
active?: () => Record<string, { type: "running" }>
|
||||
messages?: Record<string, SessionMessages>
|
||||
messages?: Record<string, Wire<SessionMessages>>
|
||||
sessions?: Array<{ id: string; parentID?: string; title?: string; agent?: string; time: { updated: number } }>
|
||||
forms?: Record<string, FormInfo[]>
|
||||
globals?: FormInfo[]
|
||||
forms?: Record<string, Array<Wire<FormInfo>>>
|
||||
globals?: Array<Wire<FormInfo>>
|
||||
globalLocation?: { directory: string; workspaceID?: string }
|
||||
permissions?: Record<string, PermissionRequest[]>
|
||||
pending?: Record<string, Awaited<ReturnType<OpenCodeClient["session"]["inbox"]["list"]>>>
|
||||
permissions?: Record<string, Array<Wire<PermissionRequest>>>
|
||||
pending?: Record<string, Wire<Awaited<ReturnType<OpenCodeClient["session"]["inbox"]["list"]>>>>
|
||||
wait?: () => Promise<void>
|
||||
}) {
|
||||
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
|
||||
let subscription = 0
|
||||
spyOn(client.event, "subscribe").mockImplementation(() => input.streams[subscription++]?.stream ?? feed().stream)
|
||||
spyOn(client.message, "list").mockImplementation((request) =>
|
||||
ok({
|
||||
data: input.messages?.[request.sessionID] ?? [
|
||||
{
|
||||
id: "msg_old",
|
||||
type: "user" as const,
|
||||
text: "previous prompt",
|
||||
files: [],
|
||||
agents: [],
|
||||
time: { created: 1 },
|
||||
},
|
||||
],
|
||||
cursor: {},
|
||||
}),
|
||||
ok(
|
||||
wire<MessageListOutput>({
|
||||
data: input.messages?.[request.sessionID] ?? [
|
||||
{
|
||||
id: "msg_old",
|
||||
type: "user" as const,
|
||||
text: "previous prompt",
|
||||
files: [],
|
||||
agents: [],
|
||||
time: { created: 1 },
|
||||
},
|
||||
],
|
||||
cursor: {},
|
||||
}),
|
||||
),
|
||||
)
|
||||
spyOn(client.permission, "list").mockImplementation((request) =>
|
||||
ok(wire<PermissionRequest[]>(input.permissions?.[request.sessionID] ?? [])),
|
||||
)
|
||||
spyOn(client.form, "list").mockImplementation((request) =>
|
||||
ok(wire<FormInfo[]>(input.forms?.[request.sessionID] ?? [])),
|
||||
)
|
||||
spyOn(client.permission, "list").mockImplementation((request) => ok(input.permissions?.[request.sessionID] ?? []))
|
||||
spyOn(client.form, "list").mockImplementation((request) => ok(input.forms?.[request.sessionID] ?? []))
|
||||
spyOn(client.form.request, "list").mockImplementation(() =>
|
||||
ok({
|
||||
location: {
|
||||
directory: input.globalLocation?.directory ?? "/tmp",
|
||||
workspaceID: input.globalLocation?.workspaceID,
|
||||
project: {
|
||||
id: "proj_1",
|
||||
ok(
|
||||
wire<Awaited<ReturnType<OpenCodeClient["form"]["request"]["list"]>>>({
|
||||
location: {
|
||||
directory: input.globalLocation?.directory ?? "/tmp",
|
||||
canonical: input.globalLocation?.directory ?? "/tmp",
|
||||
workspaceID: input.globalLocation?.workspaceID,
|
||||
project: {
|
||||
id: "proj_1",
|
||||
directory: input.globalLocation?.directory ?? "/tmp",
|
||||
canonical: input.globalLocation?.directory ?? "/tmp",
|
||||
},
|
||||
},
|
||||
},
|
||||
data: input.globals ?? [],
|
||||
}),
|
||||
data: input.globals ?? [],
|
||||
}),
|
||||
),
|
||||
)
|
||||
spyOn(client.session, "active").mockImplementation(() => ok(input.active?.() ?? {}))
|
||||
spyOn(client.session.inbox, "list").mockImplementation((request) => ok(input.pending?.[request.sessionID] ?? []))
|
||||
spyOn(client.session.inbox, "list").mockImplementation((request) =>
|
||||
ok(wire<Awaited<ReturnType<OpenCodeClient["session"]["inbox"]["list"]>>>(input.pending?.[request.sessionID] ?? [])),
|
||||
)
|
||||
spyOn(client.session, "wait").mockImplementation(() => input.wait?.() ?? ok(undefined))
|
||||
spyOn(client.session, "message").mockImplementation((request) => {
|
||||
const message = input.messages?.[request.sessionID]?.find((item) => item.id === request.messageID)
|
||||
return message ? (ok(message) as never) : Promise.reject(new Error(`message not found: ${request.messageID}`))
|
||||
return message
|
||||
? (ok(wire<SessionMessages[number]>(message)) as never)
|
||||
: Promise.reject(new Error(`message not found: ${request.messageID}`))
|
||||
})
|
||||
spyOn(client.session, "switchAgent").mockImplementation(() => ok(undefined))
|
||||
spyOn(client.session, "switchModel").mockImplementation(() => ok(undefined))
|
||||
@@ -374,7 +393,7 @@ describe("V2 mini transport", () => {
|
||||
const snapshots = ui.events.flatMap((event) => (event.type === "stream.subagent" ? [event.state] : []))
|
||||
|
||||
expect(snapshots.at(-1)?.tabs.map((item) => item.sessionID)).toEqual(["ses_child", "ses_grandchild"])
|
||||
expect(snapshots.at(-1)?.forms.map((item) => item.id)).toEqual(["frm_child", "frm_grandchild"])
|
||||
expect(snapshots.at(-1)?.forms.map((item) => String(item.id))).toEqual(["frm_child", "frm_grandchild"])
|
||||
expect(
|
||||
ui.events.find(
|
||||
(event) => event.type === "stream.view" && event.view.type === "form" && event.view.request.id === "frm_child",
|
||||
@@ -394,7 +413,7 @@ describe("V2 mini transport", () => {
|
||||
test("resolves a pre-existing child permission from its exact source message at startup", async () => {
|
||||
const events = feed()
|
||||
events.push(connected())
|
||||
const sourceMessage = {
|
||||
const sourceMessage = wire<SessionMessages[number]>({
|
||||
id: "msg_child_source",
|
||||
type: "assistant" as const,
|
||||
agent: "build",
|
||||
@@ -411,14 +430,14 @@ describe("V2 mini transport", () => {
|
||||
),
|
||||
],
|
||||
time: { created: 1 },
|
||||
}
|
||||
const permission: PermissionRequest = {
|
||||
})
|
||||
const permission = wire<PermissionRequest>({
|
||||
id: "per_child_startup",
|
||||
sessionID: "ses_child",
|
||||
action: "shell",
|
||||
resources: ["git status --short"],
|
||||
source: { type: "tool", messageID: "msg_child_source", id: "call_child_source" },
|
||||
}
|
||||
})
|
||||
const client = sdk({
|
||||
streams: [events],
|
||||
sessions: [{ id: "ses_child", parentID: "ses_1", title: "Child", time: { updated: 1 } }],
|
||||
@@ -497,7 +516,7 @@ describe("V2 mini transport", () => {
|
||||
const ui = footer()
|
||||
const transport = await createSessionTransport({
|
||||
sdk: client,
|
||||
location: { directory: "/work", workspaceID: "wrk_1" },
|
||||
location: location("/work", "wrk_1"),
|
||||
sessionID: "ses_1",
|
||||
thinking: false,
|
||||
footer: ui.api,
|
||||
@@ -574,7 +593,7 @@ describe("V2 mini transport", () => {
|
||||
const events = feed()
|
||||
events.push(connected())
|
||||
const settled = defer()
|
||||
const messages: SessionMessages = []
|
||||
const messages: Wire<SessionMessages> = []
|
||||
const client = sdk({
|
||||
streams: [events],
|
||||
messages: { ses_1: messages },
|
||||
@@ -824,7 +843,7 @@ describe("V2 mini transport", () => {
|
||||
const events = feed()
|
||||
events.push(connected())
|
||||
const idle = defer()
|
||||
const messages: SessionMessages = []
|
||||
const messages: Wire<SessionMessages> = []
|
||||
const client = sdk({ streams: [events], messages: { ses_1: messages }, wait: () => idle.promise })
|
||||
const ui = footer()
|
||||
const transport = await createSessionTransport({
|
||||
@@ -1168,18 +1187,20 @@ describe("V2 mini transport", () => {
|
||||
})
|
||||
|
||||
await Bun.sleep(0)
|
||||
expect(ui.events).toContainEqual({
|
||||
type: "stream.view",
|
||||
view: {
|
||||
type: "permission",
|
||||
request: {
|
||||
id: "per_1",
|
||||
sessionID: "ses_1",
|
||||
action: "read",
|
||||
resources: ["/tmp/file"],
|
||||
expect(ui.events).toContainEqual(
|
||||
wire<FooterEvent>({
|
||||
type: "stream.view",
|
||||
view: {
|
||||
type: "permission",
|
||||
request: {
|
||||
id: "per_1",
|
||||
sessionID: "ses_1",
|
||||
action: "read",
|
||||
resources: ["/tmp/file"],
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
await transport.close()
|
||||
})
|
||||
|
||||
@@ -1201,8 +1222,8 @@ describe("V2 mini transport", () => {
|
||||
})
|
||||
let projected = false
|
||||
spyOn(client.message, "list").mockImplementation(() =>
|
||||
ok({
|
||||
data: projected
|
||||
messageList(
|
||||
projected
|
||||
? [
|
||||
{
|
||||
id: "msg_prompt",
|
||||
@@ -1214,8 +1235,7 @@ describe("V2 mini transport", () => {
|
||||
},
|
||||
]
|
||||
: [],
|
||||
cursor: {},
|
||||
}),
|
||||
),
|
||||
)
|
||||
const ui = footer()
|
||||
const transport = await createSessionTransport({
|
||||
@@ -1281,8 +1301,8 @@ describe("V2 mini transport", () => {
|
||||
},
|
||||
})
|
||||
spyOn(client.message, "list").mockImplementation(() =>
|
||||
ok({
|
||||
data: projected
|
||||
messageList(
|
||||
projected
|
||||
? [
|
||||
{
|
||||
id: "msg_prompt",
|
||||
@@ -1294,8 +1314,7 @@ describe("V2 mini transport", () => {
|
||||
},
|
||||
]
|
||||
: [],
|
||||
cursor: {},
|
||||
}),
|
||||
),
|
||||
)
|
||||
const ui = footer()
|
||||
ui.commits.push({ kind: "user", source: "system", text: "hello", phase: "start", messageID: "msg_prompt" })
|
||||
@@ -1345,19 +1364,16 @@ describe("V2 mini transport", () => {
|
||||
const firstPrompt = spyOn(first.session, "prompt")
|
||||
const firstInterrupt = spyOn(first.session, "interrupt")
|
||||
spyOn(first.message, "list").mockImplementation(() =>
|
||||
ok({
|
||||
data: [
|
||||
{
|
||||
id: "msg_assistant",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { providerID: "test", id: "model" },
|
||||
content: [{ type: "text", text: "partial" }],
|
||||
time: { created: 1 },
|
||||
},
|
||||
],
|
||||
cursor: {},
|
||||
}),
|
||||
messageList([
|
||||
{
|
||||
id: "msg_assistant",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { providerID: "test", id: "model" },
|
||||
content: [{ type: "text", text: "partial" }],
|
||||
time: { created: 1 },
|
||||
},
|
||||
]),
|
||||
)
|
||||
let releaseHydration!: () => void
|
||||
let replacementHydrating = false
|
||||
@@ -1370,22 +1386,19 @@ describe("V2 mini transport", () => {
|
||||
releaseCatalog = resolve
|
||||
})
|
||||
spyOn(second.message, "list").mockImplementation(async (request) => {
|
||||
if (request.sessionID !== "ses_1") return ok({ data: [], cursor: {} })
|
||||
if (request.sessionID !== "ses_1") return messageList([])
|
||||
replacementHydrating = true
|
||||
await hydration
|
||||
return ok({
|
||||
data: [
|
||||
{
|
||||
id: "msg_assistant",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { providerID: "test", id: "model" },
|
||||
content: [{ type: "text", text: "partial replacement" }],
|
||||
time: { created: 1 },
|
||||
},
|
||||
],
|
||||
cursor: {},
|
||||
})
|
||||
return messageList([
|
||||
{
|
||||
id: "msg_assistant",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { providerID: "test", id: "model" },
|
||||
content: [{ type: "text", text: "partial replacement" }],
|
||||
time: { created: 1 },
|
||||
},
|
||||
])
|
||||
})
|
||||
const current: OpenCodeClient[] = []
|
||||
const ui = footer()
|
||||
@@ -1516,19 +1529,16 @@ describe("V2 mini transport", () => {
|
||||
footer: ui.api,
|
||||
})
|
||||
spyOn(client.message, "list").mockImplementation(() =>
|
||||
ok({
|
||||
data: [
|
||||
{
|
||||
id: "msg_assistant",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { providerID: "test", id: "model" },
|
||||
content: [{ type: "text", text: "the answer" }],
|
||||
time: { created: 2, completed: 3 },
|
||||
},
|
||||
],
|
||||
cursor: {},
|
||||
}),
|
||||
messageList([
|
||||
{
|
||||
id: "msg_assistant",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { providerID: "test", id: "model" },
|
||||
content: [{ type: "text", text: "the answer" }],
|
||||
time: { created: 2, completed: 3 },
|
||||
},
|
||||
]),
|
||||
)
|
||||
let reset!: () => void
|
||||
const resetting = new Promise<void>((resolve) => {
|
||||
@@ -1571,19 +1581,16 @@ describe("V2 mini transport", () => {
|
||||
events.push(connected())
|
||||
const client = sdk({ streams: [events] })
|
||||
spyOn(client.message, "list").mockImplementation(() =>
|
||||
ok({
|
||||
data: [
|
||||
{
|
||||
id: "msg_assistant",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { providerID: "test", id: "model" },
|
||||
content: [{ type: "text", text: "partial" }],
|
||||
time: { created: 2, completed: 3 },
|
||||
},
|
||||
],
|
||||
cursor: {},
|
||||
}),
|
||||
messageList([
|
||||
{
|
||||
id: "msg_assistant",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { providerID: "test", id: "model" },
|
||||
content: [{ type: "text", text: "partial" }],
|
||||
time: { created: 2, completed: 3 },
|
||||
},
|
||||
]),
|
||||
)
|
||||
const ui = footer()
|
||||
const live: StreamCommit[] = []
|
||||
@@ -1638,19 +1645,16 @@ describe("V2 mini transport", () => {
|
||||
events.push(connected())
|
||||
const client = sdk({ streams: [events] })
|
||||
spyOn(client.message, "list").mockImplementation(() =>
|
||||
ok({
|
||||
data: [
|
||||
{
|
||||
id: "msg_assistant",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { providerID: "test", id: "model" },
|
||||
content: [{ type: "text", text: "partial" }],
|
||||
time: { created: 2, completed: 3 },
|
||||
},
|
||||
],
|
||||
cursor: {},
|
||||
}),
|
||||
messageList([
|
||||
{
|
||||
id: "msg_assistant",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { providerID: "test", id: "model" },
|
||||
content: [{ type: "text", text: "partial" }],
|
||||
time: { created: 2, completed: 3 },
|
||||
},
|
||||
]),
|
||||
)
|
||||
const ui = footer()
|
||||
const live: StreamCommit[] = []
|
||||
@@ -1698,7 +1702,7 @@ describe("V2 mini transport", () => {
|
||||
const events = feed()
|
||||
events.push(connected())
|
||||
const client = sdk({ streams: [events] })
|
||||
spyOn(client.message, "list").mockImplementation(() => ok({ data: [], cursor: {} }))
|
||||
spyOn(client.message, "list").mockImplementation(() => messageList([]))
|
||||
const ui = footer()
|
||||
const live: StreamCommit[] = []
|
||||
const transport = await createSessionTransport({
|
||||
@@ -1838,20 +1842,17 @@ describe("V2 mini transport", () => {
|
||||
events.push(connected())
|
||||
const client = sdk({ streams: [events] })
|
||||
spyOn(client.message, "list").mockImplementation(() =>
|
||||
ok({
|
||||
data: [
|
||||
{
|
||||
id: "msg_assistant",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { providerID: "test", id: "model" },
|
||||
content: [],
|
||||
error: { type: "provider.transport", message: "provider failed" },
|
||||
time: { created: 2, completed: 3 },
|
||||
},
|
||||
],
|
||||
cursor: {},
|
||||
}),
|
||||
messageList([
|
||||
{
|
||||
id: "msg_assistant",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { providerID: "test", id: "model" },
|
||||
content: [],
|
||||
error: { type: "provider.transport", message: "provider failed" },
|
||||
time: { created: 2, completed: 3 },
|
||||
},
|
||||
]),
|
||||
)
|
||||
const ui = footer()
|
||||
const transport = await createSessionTransport({
|
||||
@@ -1906,20 +1907,17 @@ describe("V2 mini transport", () => {
|
||||
await Bun.sleep(0)
|
||||
expect(live[0]?.messageID).toBe("msg_assistant")
|
||||
spyOn(client.message, "list").mockImplementation(() =>
|
||||
ok({
|
||||
data: [
|
||||
{
|
||||
id: "msg_assistant",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { providerID: "test", id: "model" },
|
||||
content: [],
|
||||
error: { type: "provider.transport", message: "provider failed" },
|
||||
time: { created: 2, completed: 3 },
|
||||
},
|
||||
],
|
||||
cursor: {},
|
||||
}),
|
||||
messageList([
|
||||
{
|
||||
id: "msg_assistant",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { providerID: "test", id: "model" },
|
||||
content: [],
|
||||
error: { type: "provider.transport", message: "provider failed" },
|
||||
time: { created: 2, completed: 3 },
|
||||
},
|
||||
]),
|
||||
)
|
||||
|
||||
await transport.replayOnResize({
|
||||
@@ -1944,19 +1942,16 @@ describe("V2 mini transport", () => {
|
||||
footer: ui.api,
|
||||
})
|
||||
spyOn(client.message, "list").mockImplementation(() =>
|
||||
ok({
|
||||
data: [
|
||||
{
|
||||
id: "msg_prompt",
|
||||
type: "user",
|
||||
text: "hello",
|
||||
files: [],
|
||||
agents: [],
|
||||
time: { created: 2 },
|
||||
},
|
||||
],
|
||||
cursor: {},
|
||||
}),
|
||||
messageList([
|
||||
{
|
||||
id: "msg_prompt",
|
||||
type: "user",
|
||||
text: "hello",
|
||||
files: [],
|
||||
agents: [],
|
||||
time: { created: 2 },
|
||||
},
|
||||
]),
|
||||
)
|
||||
|
||||
await transport.replayOnResize({
|
||||
@@ -1983,33 +1978,30 @@ describe("V2 mini transport", () => {
|
||||
events.push(connected())
|
||||
const client = sdk({ streams: [events] })
|
||||
spyOn(client.message, "list").mockImplementation(() =>
|
||||
ok({
|
||||
data: [
|
||||
{
|
||||
id: "msg_b",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { providerID: "test", id: "model" },
|
||||
content: [
|
||||
{ type: "reasoning", text: "second thought" },
|
||||
{ type: "text", text: "second answer" },
|
||||
],
|
||||
time: { created: 4, completed: 5 },
|
||||
},
|
||||
{
|
||||
id: "msg_a",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { providerID: "test", id: "model" },
|
||||
content: [
|
||||
{ type: "reasoning", text: "first thought" },
|
||||
{ type: "text", text: "first answer" },
|
||||
],
|
||||
time: { created: 2, completed: 3 },
|
||||
},
|
||||
],
|
||||
cursor: {},
|
||||
}),
|
||||
messageList([
|
||||
{
|
||||
id: "msg_b",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { providerID: "test", id: "model" },
|
||||
content: [
|
||||
{ type: "reasoning", text: "second thought" },
|
||||
{ type: "text", text: "second answer" },
|
||||
],
|
||||
time: { created: 4, completed: 5 },
|
||||
},
|
||||
{
|
||||
id: "msg_a",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { providerID: "test", id: "model" },
|
||||
content: [
|
||||
{ type: "reasoning", text: "first thought" },
|
||||
{ type: "text", text: "first answer" },
|
||||
],
|
||||
time: { created: 2, completed: 3 },
|
||||
},
|
||||
]),
|
||||
)
|
||||
|
||||
const ui = footer()
|
||||
@@ -2283,7 +2275,7 @@ describe("V2 mini transport", () => {
|
||||
const ui = footer()
|
||||
const transport = await createSessionTransport({
|
||||
sdk: client,
|
||||
location: { directory: "/project", workspaceID: "wrk_1" },
|
||||
location: location("/project", "wrk_1"),
|
||||
sessionID: "ses_1",
|
||||
thinking: false,
|
||||
footer: ui.api,
|
||||
@@ -2814,14 +2806,16 @@ describe("V2 mini transport", () => {
|
||||
data: { sessionID: "ses_1" },
|
||||
})
|
||||
})
|
||||
return ok({
|
||||
id: input.id ?? "msg_cmd",
|
||||
sessionID: "ses_1",
|
||||
type: "user" as const,
|
||||
payload: { text: "evaluated template" },
|
||||
delivery: "steer" as const,
|
||||
timeCreated: 2,
|
||||
})
|
||||
return ok(
|
||||
wire<Awaited<ReturnType<OpenCodeClient["session"]["command"]>>>({
|
||||
id: input.id ?? "msg_cmd",
|
||||
sessionID: "ses_1",
|
||||
type: "user" as const,
|
||||
payload: { text: "evaluated template" },
|
||||
delivery: "steer" as const,
|
||||
timeCreated: 2,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
await transport.runPromptTurn({
|
||||
@@ -2968,14 +2962,16 @@ describe("V2 mini transport", () => {
|
||||
data: { sessionID: "ses_1" },
|
||||
})
|
||||
})
|
||||
return ok({
|
||||
id: input.id ?? "msg_skill_attachment",
|
||||
sessionID: "ses_1",
|
||||
type: "user" as const,
|
||||
payload: { text: input.text },
|
||||
delivery: "steer" as const,
|
||||
timeCreated: 2,
|
||||
})
|
||||
return ok(
|
||||
wire<Awaited<ReturnType<OpenCodeClient["session"]["prompt"]>>>({
|
||||
id: input.id ?? "msg_skill_attachment",
|
||||
sessionID: "ses_1",
|
||||
type: "user" as const,
|
||||
payload: { text: input.text },
|
||||
delivery: "steer" as const,
|
||||
timeCreated: 2,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
await transport.runPromptTurn({
|
||||
@@ -3015,10 +3011,7 @@ describe("V2 mini transport", () => {
|
||||
let refreshes = 0
|
||||
const transport = await createSessionTransport({
|
||||
sdk: client,
|
||||
location: {
|
||||
directory: "/project",
|
||||
workspaceID: "work-1",
|
||||
},
|
||||
location: location("/project", "work-1"),
|
||||
sessionID: "ses_1",
|
||||
thinking: false,
|
||||
footer: ui.api,
|
||||
@@ -3528,7 +3521,7 @@ describe("V2 mini transport", () => {
|
||||
childHydrating = true
|
||||
await hydration
|
||||
}
|
||||
return ok({ data: [], cursor: {} })
|
||||
return messageList([])
|
||||
})
|
||||
const ui = footer()
|
||||
const transport = await createSessionTransport({
|
||||
@@ -3592,34 +3585,31 @@ describe("V2 mini transport", () => {
|
||||
releaseRetry = resolve
|
||||
})
|
||||
spyOn(client.message, "list").mockImplementation(async (request) => {
|
||||
if (request.sessionID !== "ses_child") return ok({ data: [], cursor: {} })
|
||||
if (request.sessionID !== "ses_child") return messageList([])
|
||||
childRequests++
|
||||
if (childRequests === 1) {
|
||||
await stale
|
||||
return ok({ data: [], cursor: {} })
|
||||
return messageList([])
|
||||
}
|
||||
await retry
|
||||
return ok({
|
||||
data: [
|
||||
{
|
||||
id: "msg_overflow_assistant",
|
||||
type: "assistant" as const,
|
||||
agent: "explore",
|
||||
model: { providerID: "test", id: "model" },
|
||||
content: [{ type: "text" as const, id: "txt_overflow_64", text: "live 64" }],
|
||||
time: { created: 2, completed: 3 },
|
||||
},
|
||||
{
|
||||
id: "msg_overflow_baseline",
|
||||
type: "user" as const,
|
||||
text: "baseline history",
|
||||
files: [],
|
||||
agents: [],
|
||||
time: { created: 1 },
|
||||
},
|
||||
],
|
||||
cursor: {},
|
||||
})
|
||||
return messageList([
|
||||
{
|
||||
id: "msg_overflow_assistant",
|
||||
type: "assistant" as const,
|
||||
agent: "explore",
|
||||
model: { providerID: "test", id: "model" },
|
||||
content: [{ type: "text" as const, text: "live 64" }],
|
||||
time: { created: 2, completed: 3 },
|
||||
},
|
||||
{
|
||||
id: "msg_overflow_baseline",
|
||||
type: "user" as const,
|
||||
text: "baseline history",
|
||||
files: [],
|
||||
agents: [],
|
||||
time: { created: 1 },
|
||||
},
|
||||
])
|
||||
})
|
||||
const ui = footer()
|
||||
const transport = await createSessionTransport({
|
||||
@@ -3687,35 +3677,32 @@ describe("V2 mini transport", () => {
|
||||
releaseHydration = resolve
|
||||
})
|
||||
spyOn(client.message, "list").mockImplementation(async (request) => {
|
||||
if (request.sessionID !== "ses_child") return ok({ data: [], cursor: {} })
|
||||
if (request.sessionID !== "ses_child") return messageList([])
|
||||
childHydrating = true
|
||||
await hydration
|
||||
return ok({
|
||||
data: [
|
||||
{
|
||||
id: "msg_tool_projected",
|
||||
type: "assistant" as const,
|
||||
agent: "explore",
|
||||
model: { providerID: "test", id: "model" },
|
||||
content: [
|
||||
{
|
||||
type: "tool" as const,
|
||||
id: "call_overlap",
|
||||
name: "shell",
|
||||
state: {
|
||||
status: "completed" as const,
|
||||
input: { command: "projected" },
|
||||
content: [{ type: "text" as const, text: "projected result" }],
|
||||
metadata: {},
|
||||
},
|
||||
time: { created: 1, ran: 1, completed: 2 },
|
||||
return messageList([
|
||||
{
|
||||
id: "msg_tool_projected",
|
||||
type: "assistant" as const,
|
||||
agent: "explore",
|
||||
model: { providerID: "test", id: "model" },
|
||||
content: [
|
||||
{
|
||||
type: "tool" as const,
|
||||
id: "call_overlap",
|
||||
name: "shell",
|
||||
state: {
|
||||
status: "completed" as const,
|
||||
input: { command: "projected" },
|
||||
content: [{ type: "text" as const, text: "projected result" }],
|
||||
metadata: {},
|
||||
},
|
||||
],
|
||||
time: { created: 1, completed: 2 },
|
||||
},
|
||||
],
|
||||
cursor: {},
|
||||
})
|
||||
time: { created: 1, ran: 1, completed: 2 },
|
||||
},
|
||||
],
|
||||
time: { created: 1, completed: 2 },
|
||||
},
|
||||
])
|
||||
})
|
||||
const ui = footer()
|
||||
const transport = await createSessionTransport({
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { Workspace } from "@opencode-ai/schema/workspace"
|
||||
import { newSessionLocation } from "../src/config/new-session-location"
|
||||
|
||||
const workspaceID = Workspace.ID.make("wrk_1")
|
||||
|
||||
test("uses the launch directory by default", () => {
|
||||
expect(newSessionLocation("launch", "/launch", { directory: "/session", workspaceID: "work-1" })).toEqual({
|
||||
expect(newSessionLocation("launch", "/launch", { directory: "/session", workspaceID })).toEqual({
|
||||
directory: "/launch",
|
||||
})
|
||||
})
|
||||
|
||||
test("inherits the active session location when configured", () => {
|
||||
expect(newSessionLocation("inherit", "/launch", { directory: "/session", workspaceID: "work-1" })).toEqual({
|
||||
expect(newSessionLocation("inherit", "/launch", { directory: "/session", workspaceID })).toEqual({
|
||||
directory: "/session",
|
||||
workspaceID: "work-1",
|
||||
workspaceID,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -23,8 +26,8 @@ test("does not inherit an unavailable active location", () => {
|
||||
newSessionLocation(
|
||||
"inherit",
|
||||
"/launch",
|
||||
{ directory: "/deleted", workspaceID: "work-1" },
|
||||
{ directory: "/deleted", workspaceID: "work-1" },
|
||||
{ directory: "/deleted", workspaceID },
|
||||
{ directory: "/deleted", workspaceID },
|
||||
),
|
||||
).toEqual({ directory: "/launch" })
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Credential } from "@opencode-ai/schema/credential"
|
||||
import { hasConnectedProvider } from "../../src/util/connected-provider"
|
||||
|
||||
describe("hasConnectedProvider", () => {
|
||||
@@ -8,7 +9,11 @@ describe("hasConnectedProvider", () => {
|
||||
})
|
||||
|
||||
test("is true after any provider integration is connected", () => {
|
||||
expect(hasConnectedProvider([{ connections: [{ type: "credential", id: "cred_1", label: "Work" }] }])).toBe(true)
|
||||
expect(
|
||||
hasConnectedProvider([
|
||||
{ connections: [{ type: "credential", id: Credential.ID.make("cred_1"), label: "Work" }] },
|
||||
]),
|
||||
).toBe(true)
|
||||
expect(hasConnectedProvider([{ connections: [{ type: "env", name: "OPENAI_API_KEY" }] }])).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionMessageInfo } from "@opencode-ai/client"
|
||||
import { lastAssistantWithUsage, sessionFamily } from "../../src/util/session"
|
||||
import { wire } from "../fixture/wire"
|
||||
|
||||
const assistant = (id: string, input: number): SessionMessageInfo => ({
|
||||
id,
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: [],
|
||||
tokens: { input, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 0 },
|
||||
})
|
||||
const assistant = (id: string, input: number) =>
|
||||
wire<SessionMessageInfo>({
|
||||
id,
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: [],
|
||||
tokens: { input, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 0 },
|
||||
})
|
||||
|
||||
describe("util.session", () => {
|
||||
test("flattens nested subagents from any session in the family", () => {
|
||||
@@ -44,7 +46,7 @@ describe("util.session", () => {
|
||||
})
|
||||
|
||||
test("resets usage at completed compaction until the next assistant reports it", () => {
|
||||
const compaction: SessionMessageInfo = {
|
||||
const compaction = wire<SessionMessageInfo>({
|
||||
id: "msg_compaction",
|
||||
type: "compaction",
|
||||
status: "completed",
|
||||
@@ -52,7 +54,7 @@ describe("util.session", () => {
|
||||
summary: "Current state",
|
||||
recent: "",
|
||||
time: { created: 0 },
|
||||
}
|
||||
})
|
||||
const messages = [assistant("msg_before", 30), compaction]
|
||||
|
||||
expect(lastAssistantWithUsage(messages)).toBeUndefined()
|
||||
|
||||
Reference in New Issue
Block a user