mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-23 22:23:18 -04:00
Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0672a8c146 | |||
| ec23ea1564 | |||
| e91951785c | |||
| 214b12bbe3 | |||
| c0718059b7 | |||
| faf1029723 | |||
| 97d3cd0b3a | |||
| 7fd1eee35a | |||
| 2eecf076c4 | |||
| ed08f0e691 | |||
| 238e1903df | |||
| 77c7a7def7 | |||
| 88788941df | |||
| d633d794c2 | |||
| 08d52be8c2 | |||
| 79d5436d2a | |||
| 9a4bd2ba16 | |||
| e68144cb67 | |||
| e5da5bfab2 | |||
| 3a1fb5ae65 | |||
| b3d6063329 | |||
| 6c3c4bc50f | |||
| 7b349654e3 | |||
| 3d2652d7b9 | |||
| 1dea4b9391 | |||
| 15864304a5 | |||
| e312d261a8 | |||
| 2a83911c7e | |||
| 0eaa04718c | |||
| 2e5ec616d2 | |||
| b2551b4e5d | |||
| e81450809d | |||
| 2524e6be8b | |||
| b58f29a4ef | |||
| 8fec7e0e91 | |||
| 94f9d32040 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@opencode-ai/core": patch
|
||||
---
|
||||
|
||||
Nested AGENTS.md instructions are re-injected after compaction. Previously the in-memory dedup claim outlived the synthetic message that compaction dropped from model-visible history, so nested instructions were silently lost for the rest of the process lifetime. The claim now only guards in-flight loads; the synthetic message metadata in durable history is the sole lasting ledger, so any history truncation (compaction, revert) self-heals on the next read in that subtree.
|
||||
@@ -37,6 +37,10 @@ const requiresThoughtSignatureFallback = (modelID: string) => {
|
||||
return !/(^|\/)gemini-robotics-er-1\.5(?:[.-]|$)/i.test(modelID)
|
||||
}
|
||||
|
||||
// Gemini 3 accepts media nested inside function responses; matched Gemini 2.5 variants reject it,
|
||||
// so their tool-result attachments lower as a separate user turn instead.
|
||||
const routesLegacyToolMedia = (modelID: string) => /gemini-2[.-]5(?:[.-]|$)/i.test(modelID)
|
||||
|
||||
export interface OptionsInput {
|
||||
readonly [key: string]: unknown
|
||||
readonly cachedContent?: string
|
||||
@@ -284,8 +288,16 @@ const lowerToolCall = (part: ToolCallPart) => ({
|
||||
|
||||
const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMRequest) {
|
||||
const contents: GeminiContent[] = []
|
||||
const legacyToolMedia = routesLegacyToolMedia(request.model.id)
|
||||
let pendingMedia: GeminiInlineDataPart[] | undefined
|
||||
const flushMedia = () => {
|
||||
if (!pendingMedia) return
|
||||
contents.push({ role: "user", parts: [{ text: "Attached media from tool result:" }, ...pendingMedia] })
|
||||
pendingMedia = undefined
|
||||
}
|
||||
|
||||
for (const message of request.messages) {
|
||||
if (message.role !== "tool") flushMedia()
|
||||
if (message.role === "system") {
|
||||
const part = yield* ProviderShared.wrappedSystemUpdate("Gemini", message)
|
||||
const previous = contents.at(-1)
|
||||
@@ -367,6 +379,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
||||
const value = ProviderShared.normalizeToolFile(item)
|
||||
media.push({ inlineData: { mimeType: value.mime, data: value.base64 } })
|
||||
}
|
||||
if (legacyToolMedia && media.length > 0) (pendingMedia ??= []).push(...media)
|
||||
parts.push({
|
||||
functionResponse: {
|
||||
id: functionCallId(part.providerMetadata),
|
||||
@@ -375,7 +388,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
||||
name: part.name,
|
||||
content: text.join("\n"),
|
||||
},
|
||||
parts: media.length > 0 ? media : undefined,
|
||||
parts: legacyToolMedia || media.length === 0 ? undefined : media,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -387,6 +400,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
||||
else contents.push({ role: "user", parts })
|
||||
}
|
||||
|
||||
flushMedia()
|
||||
return contents
|
||||
})
|
||||
|
||||
|
||||
@@ -328,14 +328,18 @@ describe("Gemini route", () => {
|
||||
functionResponse: {
|
||||
name: "read",
|
||||
response: { name: "read", content: "Image read successfully" },
|
||||
parts: [
|
||||
{ inlineData: { mimeType: "image/png", data: "AAECAw==" } },
|
||||
{ inlineData: { mimeType: "application/pdf", data: "JVBERi0xLjQ=" } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
parts: [
|
||||
{ text: "Attached media from tool result:" },
|
||||
{ inlineData: { mimeType: "image/png", data: "AAECAw==" } },
|
||||
{ inlineData: { mimeType: "application/pdf", data: "JVBERi0xLjQ=" } },
|
||||
],
|
||||
},
|
||||
])
|
||||
expect(JSON.stringify(prepared.body.contents)).not.toContain('"content":"AAECAw=="')
|
||||
}),
|
||||
@@ -368,11 +372,161 @@ describe("Gemini route", () => {
|
||||
functionResponse: {
|
||||
name: "read",
|
||||
response: { name: "read", content: "" },
|
||||
parts: [{ inlineData: { mimeType: "image/jpeg", data: "/9j/" } }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
parts: [
|
||||
{ text: "Attached media from tool result:" },
|
||||
{ inlineData: { mimeType: "image/jpeg", data: "/9j/" } },
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("nests media inside function responses for gemini 3", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: gemini3,
|
||||
messages: [
|
||||
Message.assistant([
|
||||
ToolCallPart.make({
|
||||
id: "call_image",
|
||||
name: "read",
|
||||
input: { path: "pixel.png" },
|
||||
providerMetadata: { google: { thoughtSignature: "sig_1" } },
|
||||
}),
|
||||
]),
|
||||
Message.tool({
|
||||
id: "call_image",
|
||||
name: "read",
|
||||
result: {
|
||||
type: "content",
|
||||
value: [
|
||||
{ type: "text", text: "Image read successfully" },
|
||||
{ type: "file", uri: "data:image/png;base64,AAECAw==", mime: "image/png", name: "pixel.png" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.contents).toEqual([
|
||||
{
|
||||
role: "model",
|
||||
parts: [{ functionCall: { name: "read", args: { path: "pixel.png" } }, thoughtSignature: "sig_1" }],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
name: "read",
|
||||
response: { name: "read", content: "Image read successfully" },
|
||||
parts: [{ inlineData: { mimeType: "image/png", data: "AAECAw==" } }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("flushes pending media before system update text", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.assistant([ToolCallPart.make({ id: "call_1", name: "shot", input: {} })]),
|
||||
Message.tool({
|
||||
id: "call_1",
|
||||
name: "shot",
|
||||
result: {
|
||||
type: "content",
|
||||
value: [{ type: "file", uri: "data:image/png;base64,AAEC", mime: "image/png" }],
|
||||
},
|
||||
}),
|
||||
Message.system("Update."),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.contents).toEqual([
|
||||
{ role: "model", parts: [{ functionCall: { name: "shot", args: {} } }] },
|
||||
{
|
||||
role: "user",
|
||||
parts: [{ functionResponse: { name: "shot", response: { name: "shot", content: "" } } }],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
parts: [
|
||||
{ text: "Attached media from tool result:" },
|
||||
{ inlineData: { mimeType: "image/png", data: "AAEC" } },
|
||||
{ text: "<system-update>\nUpdate.\n</system-update>" },
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("collects legacy tool media into one turn after merged responses", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.assistant([
|
||||
ToolCallPart.make({ id: "call_1", name: "shot", input: {} }),
|
||||
ToolCallPart.make({ id: "call_2", name: "shot", input: {} }),
|
||||
]),
|
||||
Message.tool({
|
||||
id: "call_1",
|
||||
name: "shot",
|
||||
result: {
|
||||
type: "content",
|
||||
value: [{ type: "file", uri: "data:image/png;base64,AAEC", mime: "image/png" }],
|
||||
},
|
||||
}),
|
||||
Message.tool({
|
||||
id: "call_2",
|
||||
name: "shot",
|
||||
result: {
|
||||
type: "content",
|
||||
value: [{ type: "text", text: "no image here" }],
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.contents).toEqual([
|
||||
{
|
||||
role: "model",
|
||||
parts: [
|
||||
{ functionCall: { name: "shot", args: {} } },
|
||||
{ functionCall: { name: "shot", args: {} } },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
parts: [
|
||||
{ functionResponse: { name: "shot", response: { name: "shot", content: "" } } },
|
||||
{ functionResponse: { name: "shot", response: { name: "shot", content: "no image here" } } },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
parts: [
|
||||
{ text: "Attached media from tool result:" },
|
||||
{ inlineData: { mimeType: "image/png", data: "AAEC" } },
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { ComposerModel } from "./model"
|
||||
import { createComposerEditor } from "./editor/interaction"
|
||||
import type { ComposerPersistedState, ComposerSuggestion } from "./types"
|
||||
import { buildPromptRequest } from "./request"
|
||||
import { promptLength } from "./prompt-parts"
|
||||
import { SessionPreview } from "@/session/story-model"
|
||||
import { Skill } from "@opencode-ai/schema/skill"
|
||||
import { resolveSessionComposerSelection } from "@/session/composer/selection"
|
||||
@@ -57,7 +58,7 @@ function ComposerStory(props: {
|
||||
}) {
|
||||
const [draft, setDraft] = createStore<ComposerPersistedState>({
|
||||
prompt: props.prompt ?? [{ type: "text", content: "", start: 0, end: 0 }],
|
||||
cursor: props.prompt?.reduce((length, part) => length + ("content" in part ? part.content.length : 0), 0) ?? 0,
|
||||
cursor: props.prompt ? promptLength(props.prompt) : 0,
|
||||
model: { providerID: STORY_MODEL.providerID, modelID: STORY_MODEL.id, variant: STORY_MODEL.variant },
|
||||
context: { items: props.comments ?? [] },
|
||||
})
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
ComposerPersistedState,
|
||||
ComposerPrompt,
|
||||
} from "../types"
|
||||
import { promptLength } from "../prompt-parts"
|
||||
|
||||
export type ComposerStateStore = [
|
||||
Store<ComposerPersistedState> | Accessor<Store<ComposerPersistedState>>,
|
||||
@@ -134,7 +135,3 @@ function withOffsets(prompt: ComposerPrompt): ComposerPrompt {
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function promptLength(prompt: ComposerPrompt) {
|
||||
return prompt.reduce((length, part) => length + ("content" in part ? part.content.length : 0), 0)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
type ComposerInteractionCommand,
|
||||
type ComposerInteractionEvent,
|
||||
} from "../suggestions/machine"
|
||||
import { clonePrompt, promptLength } from "../prompt-parts"
|
||||
|
||||
export type ComposerSelectControl = {
|
||||
options: Accessor<ComposerOption[]>
|
||||
@@ -434,16 +435,6 @@ function canNavigateHistory(direction: "up" | "down", text: string, cursor: numb
|
||||
return position === text.length
|
||||
}
|
||||
|
||||
function clonePrompt(prompt: ComposerPersistedState["prompt"]): ComposerPersistedState["prompt"] {
|
||||
return prompt.map((part) =>
|
||||
part.type === "file" ? { ...part, selection: part.selection ? { ...part.selection } : undefined } : { ...part },
|
||||
)
|
||||
}
|
||||
|
||||
function promptLength(prompt: ComposerPersistedState["prompt"]) {
|
||||
return prompt.reduce((length, part) => length + ("content" in part ? part.content.length : 0), 0)
|
||||
}
|
||||
|
||||
function editorCursor(editor: HTMLElement) {
|
||||
const selection = window.getSelection()
|
||||
if (!selection?.rangeCount || !editor.contains(selection.anchorNode)) return editor.textContent?.length ?? 0
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Prompt } from "@/composer/state"
|
||||
import { clonePromptParts, prependHistoryEntry, promptLength, type PromptHistoryComment } from "./entry"
|
||||
import { prependHistoryEntry, type PromptHistoryComment } from "./entry"
|
||||
import { upgradeHistoryState } from "./store"
|
||||
|
||||
const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
|
||||
@@ -34,31 +34,32 @@ describe("Composer history", () => {
|
||||
expect(dedupedComments).toBe(commentsOnly)
|
||||
})
|
||||
|
||||
test("insertion isolates canonical entries from source mutations", () => {
|
||||
const prompt: Prompt = [
|
||||
{
|
||||
type: "file",
|
||||
path: "src/a.ts",
|
||||
content: "@src/a.ts",
|
||||
start: 0,
|
||||
end: 9,
|
||||
selection: { startLine: 1, startChar: 0, endLine: 2, endChar: 0 },
|
||||
},
|
||||
]
|
||||
const comments = [comment("c1")]
|
||||
const entries = prependHistoryEntry([], prompt, comments)
|
||||
const stored = entries[0]
|
||||
|
||||
if (prompt[0]?.type !== "file" || stored?.prompt[0]?.type !== "file") throw new Error("expected file")
|
||||
prompt[0].selection!.startLine = 9
|
||||
comments[0].selection.start = 9
|
||||
|
||||
expect(stored.prompt[0].selection?.startLine).toBe(1)
|
||||
expect(stored.comments[0]?.selection.start).toBe(2)
|
||||
})
|
||||
|
||||
test("upgrades stored prompt arrays once at the persistence boundary", () => {
|
||||
expect(upgradeHistoryState({ entries: [text("stored")] })).toEqual({
|
||||
entries: [{ prompt: text("stored"), comments: [] }],
|
||||
})
|
||||
})
|
||||
|
||||
test("helpers clone prompt and count text content length", () => {
|
||||
const original: Prompt = [
|
||||
{ type: "text", content: "one", start: 0, end: 3 },
|
||||
{
|
||||
type: "file",
|
||||
path: "src/a.ts",
|
||||
content: "@src/a.ts",
|
||||
start: 3,
|
||||
end: 12,
|
||||
selection: { startLine: 1, startChar: 1, endLine: 2, endChar: 1 },
|
||||
},
|
||||
{ type: "image", id: "1", filename: "img.png", mime: "image/png", blob: { id: "blob", url: "blob:test" } },
|
||||
]
|
||||
const copy = clonePromptParts(original)
|
||||
expect(copy).not.toBe(original)
|
||||
expect(promptLength(copy)).toBe(12)
|
||||
if (copy[1]?.type !== "file") throw new Error("expected file")
|
||||
copy[1].selection!.startLine = 9
|
||||
if (original[1]?.type !== "file") throw new Error("expected file")
|
||||
expect(original[1].selection?.startLine).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Prompt } from "@/composer/state"
|
||||
import type { SelectedLineRange } from "@/workspaces/files/model"
|
||||
import { clonePrompt } from "../prompt-parts"
|
||||
|
||||
export const MAX_HISTORY = 100
|
||||
|
||||
@@ -20,19 +21,6 @@ export type PromptHistoryEntry = {
|
||||
|
||||
export type PromptHistoryStoredEntry = PromptHistoryEntry
|
||||
|
||||
export function clonePromptParts(prompt: Prompt): Prompt {
|
||||
return prompt.map((part) => {
|
||||
if (part.type === "text") return { ...part }
|
||||
if (part.type === "image") return { ...part }
|
||||
if (part.type === "agent") return { ...part }
|
||||
if (part.type === "skill") return { ...part }
|
||||
return {
|
||||
...part,
|
||||
selection: part.selection ? { ...part.selection } : undefined,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function cloneSelection(selection: SelectedLineRange): SelectedLineRange {
|
||||
return {
|
||||
start: selection.start,
|
||||
@@ -49,17 +37,6 @@ export function clonePromptHistoryComments(comments: PromptHistoryComment[]) {
|
||||
}))
|
||||
}
|
||||
|
||||
export function normalizePromptHistoryEntry(entry: PromptHistoryStoredEntry): PromptHistoryEntry {
|
||||
return {
|
||||
prompt: clonePromptParts(entry.prompt),
|
||||
comments: clonePromptHistoryComments(entry.comments),
|
||||
}
|
||||
}
|
||||
|
||||
export function promptLength(prompt: Prompt) {
|
||||
return prompt.reduce((len, part) => len + ("content" in part ? part.content.length : 0), 0)
|
||||
}
|
||||
|
||||
export function prependHistoryEntry(
|
||||
entries: PromptHistoryStoredEntry[],
|
||||
prompt: Prompt,
|
||||
@@ -75,7 +52,7 @@ export function prependHistoryEntry(
|
||||
if (!text && !hasImages && !hasComments) return entries
|
||||
|
||||
const entry = {
|
||||
prompt: clonePromptParts(prompt),
|
||||
prompt: clonePrompt(prompt),
|
||||
comments: clonePromptHistoryComments(comments),
|
||||
} satisfies PromptHistoryEntry
|
||||
const last = entries[0]
|
||||
@@ -96,9 +73,7 @@ function isCommentEqual(commentA: PromptHistoryComment, commentB: PromptHistoryC
|
||||
)
|
||||
}
|
||||
|
||||
function isPromptEqual(promptA: PromptHistoryStoredEntry, promptB: PromptHistoryStoredEntry) {
|
||||
const entryA = normalizePromptHistoryEntry(promptA)
|
||||
const entryB = normalizePromptHistoryEntry(promptB)
|
||||
function isPromptEqual(entryA: PromptHistoryStoredEntry, entryB: PromptHistoryStoredEntry) {
|
||||
if (entryA.prompt.length !== entryB.prompt.length) return false
|
||||
for (let i = 0; i < entryA.prompt.length; i++) {
|
||||
const partA = entryA.prompt[i]
|
||||
|
||||
@@ -3,11 +3,11 @@ import type { Prompt } from "@/composer/state"
|
||||
import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
import {
|
||||
clonePromptHistoryComments,
|
||||
clonePromptParts,
|
||||
prependHistoryEntry,
|
||||
type PromptHistoryComment,
|
||||
type PromptHistoryStoredEntry,
|
||||
} from "./entry"
|
||||
import { clonePrompt } from "../prompt-parts"
|
||||
|
||||
export type ComposerHistoryStore = {
|
||||
entries: (mode: "normal" | "shell") => PromptHistoryStoredEntry[]
|
||||
@@ -23,7 +23,7 @@ export function upgradeHistoryState(value: unknown) {
|
||||
return {
|
||||
...value,
|
||||
entries: entries.flatMap((entry): PromptHistoryStoredEntry[] => {
|
||||
if (Array.isArray(entry)) return [{ prompt: clonePromptParts(entry as Prompt), comments: [] }]
|
||||
if (Array.isArray(entry)) return [{ prompt: clonePrompt(entry as Prompt), comments: [] }]
|
||||
if (!entry || typeof entry !== "object" || !("prompt" in entry) || !Array.isArray(entry.prompt)) return []
|
||||
if (!("comments" in entry) || !Array.isArray(entry.comments)) return []
|
||||
return [entry as PromptHistoryStoredEntry]
|
||||
@@ -64,7 +64,7 @@ export function createComposerHistory() {
|
||||
add(prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) {
|
||||
const ready = mode === "shell" ? shellInit : normalInit
|
||||
if (!(ready instanceof Promise)) return history.add(prompt, mode, comments)
|
||||
const saved = clonePromptParts(prompt)
|
||||
const saved = clonePrompt(prompt)
|
||||
const metadata = clonePromptHistoryComments(comments)
|
||||
void ready.then(() => history.add(saved, mode, metadata))
|
||||
},
|
||||
|
||||
@@ -18,7 +18,7 @@ import { formatServerError } from "@/runtime/server/errors"
|
||||
import { Skill } from "@opencode-ai/schema/skill"
|
||||
import type { ComposerAdapter, ComposerControls } from "./adapter"
|
||||
import type { ImageAttachmentPart } from "./state"
|
||||
import { normalizePromptHistoryEntry, type PromptHistoryComment } from "./history/entry"
|
||||
import type { PromptHistoryComment } from "./history/entry"
|
||||
import { createComposerHistory } from "./history/store"
|
||||
import { composerPlaceholder } from "./placeholder"
|
||||
import { createComposerSubmit } from "./submit"
|
||||
@@ -284,11 +284,7 @@ export function createComposerModel(adapter: ComposerAdapter): ComposerModel {
|
||||
store: prompt.store,
|
||||
state: interaction,
|
||||
history: {
|
||||
entries: (mode) =>
|
||||
history.entries(mode).map((value) => {
|
||||
const entry = normalizePromptHistoryEntry(value)
|
||||
return { prompt: entry.prompt, metadata: entry.comments }
|
||||
}),
|
||||
entries: (mode) => history.entries(mode).map((entry) => ({ prompt: entry.prompt, metadata: entry.comments })),
|
||||
add: (value, mode) => history.add(value, mode, mode === "shell" ? [] : historyComments()),
|
||||
capture: historyComments,
|
||||
restore: (metadata) => restoreHistoryComments(metadata as PromptHistoryComment[]),
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Prompt } from "./state"
|
||||
import { clonePrompt, promptLength } from "./prompt-parts"
|
||||
|
||||
describe("composer prompt parts", () => {
|
||||
test("clones parts shallowly and copies file selections", () => {
|
||||
const original: Prompt = [
|
||||
{ type: "text", content: "one", start: 0, end: 3 },
|
||||
{
|
||||
type: "file",
|
||||
path: "src/a.ts",
|
||||
content: "@src/a.ts",
|
||||
start: 3,
|
||||
end: 12,
|
||||
selection: { startLine: 1, startChar: 1, endLine: 2, endChar: 1 },
|
||||
},
|
||||
{ type: "image", id: "1", filename: "img.png", mime: "image/png", blob: { id: "blob", url: "blob:test" } },
|
||||
]
|
||||
|
||||
const copy = clonePrompt(original)
|
||||
|
||||
expect(copy).not.toBe(original)
|
||||
expect(copy[0]).not.toBe(original[0])
|
||||
expect(copy[1]).not.toBe(original[1])
|
||||
expect(copy[2]).not.toBe(original[2])
|
||||
if (copy[1]?.type !== "file" || original[1]?.type !== "file") throw new Error("expected file parts")
|
||||
if (copy[2]?.type !== "image" || original[2]?.type !== "image") throw new Error("expected image parts")
|
||||
expect(copy[2].blob).toBe(original[2].blob)
|
||||
expect(copy[1].selection).not.toBe(original[1].selection)
|
||||
copy[1].selection!.startLine = 9
|
||||
expect(original[1].selection?.startLine).toBe(1)
|
||||
})
|
||||
|
||||
test("counts the content of text and mention parts", () => {
|
||||
const prompt: Prompt = [
|
||||
{ type: "text", content: "one", start: 0, end: 3 },
|
||||
{ type: "agent", content: "@build", start: 3, end: 9, name: "build" },
|
||||
{ type: "image", id: "1", filename: "img.png", mime: "image/png", blob: { id: "blob", url: "blob:test" } },
|
||||
]
|
||||
|
||||
expect(promptLength(prompt)).toBe(9)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { Prompt } from "./state"
|
||||
|
||||
export function clonePrompt(prompt: Prompt): Prompt {
|
||||
return prompt.map((part) =>
|
||||
part.type === "file" ? { ...part, selection: part.selection ? { ...part.selection } : undefined } : { ...part },
|
||||
)
|
||||
}
|
||||
|
||||
export function promptLength(prompt: Prompt) {
|
||||
return prompt.reduce((length, part) => length + ("content" in part ? part.content.length : 0), 0)
|
||||
}
|
||||
@@ -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 " },
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { BlobReference } from "@/runtime/persistence/drafts"
|
||||
import type { Platform } from "@/runtime/platform/platform"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { Skill } from "@opencode-ai/schema/skill"
|
||||
import { clonePrompt } from "./prompt-parts"
|
||||
|
||||
interface PartBase {
|
||||
content: string
|
||||
@@ -108,26 +109,6 @@ type InitialPrompt = {
|
||||
model?: PromptModel
|
||||
}
|
||||
|
||||
function cloneSelection(selection?: FileSelection) {
|
||||
if (!selection) return undefined
|
||||
return { ...selection }
|
||||
}
|
||||
|
||||
function clonePart(part: ContentPart): ContentPart {
|
||||
if (part.type === "text") return { ...part }
|
||||
if (part.type === "image") return { ...part }
|
||||
if (part.type === "agent") return { ...part }
|
||||
if (part.type === "skill") return { ...part }
|
||||
return {
|
||||
...part,
|
||||
selection: cloneSelection(part.selection),
|
||||
}
|
||||
}
|
||||
|
||||
function clonePrompt(prompt: Prompt): Prompt {
|
||||
return prompt.map(clonePart)
|
||||
}
|
||||
|
||||
function contextItemKey(item: ContextItem) {
|
||||
if (item.type !== "file") return item.type
|
||||
const start = item.selection?.startLine
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import type { Accessor } from "solid-js"
|
||||
import { clonePromptParts, type PromptHistoryComment } from "./history/entry"
|
||||
import type { PromptHistoryComment } from "./history/entry"
|
||||
import type { ImageAttachmentPart, Prompt } from "./state"
|
||||
import { clonePrompt, promptLength } from "./prompt-parts"
|
||||
import type { ComposerAdapter, ComposerSelection, ComposerSession } from "./adapter"
|
||||
import { createComposerSubmission } from "./submission-state"
|
||||
import { buildPromptRequest } from "./request"
|
||||
@@ -48,7 +49,7 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
|
||||
|
||||
const submission = createComposerSubmission({
|
||||
target: input.adapter.state,
|
||||
prompt: clonePromptParts(input.adapter.state.current()),
|
||||
prompt: clonePrompt(input.adapter.state.current()),
|
||||
context: input.adapter.state.context.items().map((item) => ({
|
||||
...item,
|
||||
selection: item.selection ? { ...item.selection } : undefined,
|
||||
@@ -317,7 +318,3 @@ function failSubmission(
|
||||
restore()
|
||||
input.notify.failed(kind, error)
|
||||
}
|
||||
|
||||
function promptLength(prompt: Prompt) {
|
||||
return prompt.reduce((length, part) => length + ("content" in part ? part.content.length : 0), 0)
|
||||
}
|
||||
|
||||
@@ -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: [] },
|
||||
])
|
||||
|
||||
@@ -53,22 +53,6 @@ function runAll(list: Array<() => Promise<unknown>>) {
|
||||
return Promise.allSettled(list.map((item) => item()))
|
||||
}
|
||||
|
||||
function showErrors(input: {
|
||||
errors: unknown[]
|
||||
title: string
|
||||
translate: (key: string, vars?: Record<string, string | number>) => string
|
||||
formatMoreCount: (count: number) => string
|
||||
}) {
|
||||
if (input.errors.length === 0) return
|
||||
const message = formatServerError(input.errors[0], input.translate)
|
||||
const more = input.errors.length > 1 ? input.formatMoreCount(input.errors.length - 1) : ""
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: input.title,
|
||||
description: message + more,
|
||||
})
|
||||
}
|
||||
|
||||
export const loadGlobalConfigQuery = (scope: ServerScope) =>
|
||||
queryOptions({
|
||||
queryKey: [scope, "config"],
|
||||
@@ -126,9 +110,6 @@ export async function bootstrapGlobal(input: {
|
||||
readonly worktree: WorktreeApi
|
||||
}
|
||||
scope: ServerScope
|
||||
requestFailedTitle: string
|
||||
translate: (key: string, vars?: Record<string, string | number>) => string
|
||||
formatMoreCount: (count: number) => string
|
||||
setGlobalStore: SetStoreFunction<GlobalStore>
|
||||
queryClient: QueryClient
|
||||
}) {
|
||||
@@ -141,12 +122,6 @@ export async function bootstrapGlobal(input: {
|
||||
.then((data) => input.setGlobalStore("project", data)),
|
||||
]
|
||||
await runAll(slow)
|
||||
// showErrors({
|
||||
// errors: errors(),
|
||||
// title: input.requestFailedTitle,
|
||||
// translate: input.translate,
|
||||
// formatMoreCount: input.formatMoreCount,
|
||||
// })
|
||||
}
|
||||
|
||||
function projectID(directory: string, projects: Project[]) {
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -97,9 +97,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
|
||||
await bootstrapGlobal({
|
||||
serverAPI: serverSDK.api,
|
||||
scope: serverSDK.scope,
|
||||
requestFailedTitle: language.t("common.requestFailed"),
|
||||
translate: language.t,
|
||||
formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
|
||||
setGlobalStore: setBootStore,
|
||||
queryClient,
|
||||
})
|
||||
|
||||
@@ -10,7 +10,7 @@ import { createComposerModel } from "@/composer/model"
|
||||
import { useComposerState } from "@/composer/persistence"
|
||||
import { createComposerControls } from "@/composer/selection"
|
||||
import { setCursorPosition } from "@/composer/editor/dom"
|
||||
import { promptLength } from "@/composer/history/entry"
|
||||
import { promptLength } from "@/composer/prompt-parts"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useLocal } from "@/providers/models/selection"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -16,12 +16,9 @@ import {
|
||||
createPermissionScopeController,
|
||||
createShellOptions,
|
||||
createShellSettingsController,
|
||||
createSoundSettingsController,
|
||||
soundOptions,
|
||||
type AppearanceSettingsController,
|
||||
type PermissionScopeController,
|
||||
type ShellSettingsController,
|
||||
type SoundSettingsController,
|
||||
} from "./controllers"
|
||||
import "@/settings/settings.css"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
@@ -50,24 +47,6 @@ const fontSettings = {
|
||||
input: "setTerminal",
|
||||
},
|
||||
} as const
|
||||
const soundSettings = {
|
||||
agent: {
|
||||
action: "settings-sounds-agent",
|
||||
title: "settings.general.sounds.agent.title",
|
||||
description: "settings.general.sounds.agent.description",
|
||||
},
|
||||
permissions: {
|
||||
action: "settings-sounds-permissions",
|
||||
title: "settings.general.sounds.permissions.title",
|
||||
description: "settings.general.sounds.permissions.description",
|
||||
},
|
||||
errors: {
|
||||
action: "settings-sounds-errors",
|
||||
title: "settings.general.sounds.errors.title",
|
||||
description: "settings.general.sounds.errors.description",
|
||||
},
|
||||
} as const
|
||||
|
||||
const PermissionScopeSetting: Component<{ controller: PermissionScopeController }> = (props) => {
|
||||
const language = useLanguage()
|
||||
return (
|
||||
@@ -228,43 +207,6 @@ const FontSetting: Component<{
|
||||
)
|
||||
}
|
||||
|
||||
const SoundsSection: Component<{ controller: SoundSettingsController }> = (props) => {
|
||||
const language = useLanguage()
|
||||
return (
|
||||
<div class="settings-section">
|
||||
<h3 class="settings-section-title">{language.t("settings.general.section.sounds")}</h3>
|
||||
<SettingsList>
|
||||
<SoundSetting kind="agent" channel={props.controller.agent} />
|
||||
<SoundSetting kind="permissions" channel={props.controller.permissions} />
|
||||
<SoundSetting kind="errors" channel={props.controller.errors} />
|
||||
</SettingsList>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const SoundSetting: Component<{
|
||||
kind: "agent" | "permissions" | "errors"
|
||||
channel: SoundSettingsController["agent"]
|
||||
}> = (props) => {
|
||||
const language = useLanguage()
|
||||
const config = () => soundSettings[props.kind]
|
||||
return (
|
||||
<SettingsRow title={language.t(config().title)} description={language.t(config().description)}>
|
||||
<Select
|
||||
data-action={config().action}
|
||||
options={soundOptions}
|
||||
current={props.channel.current()}
|
||||
value={(option) => option.id}
|
||||
label={(option) => language.t(option.label)}
|
||||
onHighlight={props.channel.highlight}
|
||||
onSelect={props.channel.select}
|
||||
placement="bottom-end"
|
||||
gutter={6}
|
||||
/>
|
||||
</SettingsRow>
|
||||
)
|
||||
}
|
||||
|
||||
const LanguageSetting = () => {
|
||||
const language = useLanguage()
|
||||
const options = createMemo(() =>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { createMemo, createResource, For, type JSXElement, Show } from "solid-js"
|
||||
import { createMemo, createResource, For, Index, type JSXElement, Show } from "solid-js"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useMcpToggle } from "@/providers/connect/mcp"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
@@ -27,14 +27,12 @@ export function StatusPopoverBody(props: { shown: boolean }) {
|
||||
const language = useLanguage()
|
||||
|
||||
const toggleMcp = useMcpToggle(() => sdk().directory)
|
||||
const mcp = () => data.location.mcp.server.list({ directory: sdk().directory }) ?? []
|
||||
const mcpNames = createMemo(() =>
|
||||
mcp()
|
||||
.map((server) => server.name)
|
||||
.sort((a, b) => a.localeCompare(b)),
|
||||
const mcpServers = createMemo(() =>
|
||||
(data.location.mcp.server.list({ directory: sdk().directory }) ?? []).toSorted((a, b) =>
|
||||
a.name.localeCompare(b.name),
|
||||
),
|
||||
)
|
||||
const mcpStatus = (name: string) => mcp().find((server) => server.name === name)?.status.status
|
||||
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
|
||||
const mcpConnected = createMemo(() => mcpServers().filter((server) => server.status.status === "connected").length)
|
||||
const [pluginList] = createResource(
|
||||
() => (props.shown ? sdk().directory : undefined),
|
||||
(directory) => serverSDK.api.plugin.list({ location: { directory } }).then((result) => result.data),
|
||||
@@ -58,26 +56,25 @@ export function StatusPopoverBody(props: { shown: boolean }) {
|
||||
{language.t("status.popover.tab.mcp")}
|
||||
</Tabs.Trigger>
|
||||
{/* TODO: Restore LSP status when V2 exposes it. */}
|
||||
<Show when={true}>
|
||||
<Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular">
|
||||
{pluginCount() > 0 ? `${pluginCount()} ` : ""}
|
||||
{language.t("status.popover.tab.plugins")}
|
||||
</Tabs.Trigger>
|
||||
</Show>
|
||||
<Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular">
|
||||
{pluginCount() > 0 ? `${pluginCount()} ` : ""}
|
||||
{language.t("status.popover.tab.plugins")}
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Content value="mcp">
|
||||
<div class="flex flex-col px-2 pb-2">
|
||||
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
|
||||
<Show
|
||||
when={mcpNames().length > 0}
|
||||
when={mcpServers().length > 0}
|
||||
fallback={
|
||||
<div class="text-14-regular text-text-base text-center my-auto">{language.t("dialog.mcp.empty")}</div>
|
||||
}
|
||||
>
|
||||
<For each={mcpNames()}>
|
||||
{(name) => {
|
||||
const status = () => mcpStatus(name)
|
||||
<Index each={mcpServers()}>
|
||||
{(server) => {
|
||||
const name = () => server().name
|
||||
const status = () => server().status.status
|
||||
const enabled = () => status() === "connected"
|
||||
return (
|
||||
<button
|
||||
@@ -85,9 +82,9 @@ export function StatusPopoverBody(props: { shown: boolean }) {
|
||||
class="flex items-center gap-2 w-full min-h-8 pl-3 pr-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
|
||||
onClick={() => {
|
||||
if (toggleMcp.isPending) return
|
||||
toggleMcp.mutate(name)
|
||||
toggleMcp.mutate(name())
|
||||
}}
|
||||
disabled={toggleMcp.isPending && toggleMcp.variables === name}
|
||||
disabled={toggleMcp.isPending && toggleMcp.variables === name()}
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
@@ -100,7 +97,7 @@ export function StatusPopoverBody(props: { shown: boolean }) {
|
||||
/>
|
||||
<span class="flex flex-col min-w-0 flex-1">
|
||||
<span class="flex items-center gap-2 min-w-0">
|
||||
<span class="text-14-regular text-text-base truncate">{name}</span>
|
||||
<span class="text-14-regular text-text-base truncate">{name()}</span>
|
||||
</span>
|
||||
<Show when={status() === "needs_auth"}>
|
||||
<span class="text-11-regular text-text-weaker truncate">
|
||||
@@ -112,43 +109,41 @@ export function StatusPopoverBody(props: { shown: boolean }) {
|
||||
<Switch
|
||||
appearance="standard"
|
||||
checked={enabled()}
|
||||
disabled={toggleMcp.isPending && toggleMcp.variables === name}
|
||||
disabled={toggleMcp.isPending && toggleMcp.variables === name()}
|
||||
onChange={() => {
|
||||
if (toggleMcp.isPending) return
|
||||
toggleMcp.mutate(name)
|
||||
toggleMcp.mutate(name())
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Index>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<Show when={true}>
|
||||
<Tabs.Content value="plugins">
|
||||
<div class="flex flex-col px-2 pb-2">
|
||||
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
|
||||
<Show
|
||||
when={plugins().length > 0}
|
||||
fallback={<div class="text-14-regular text-text-base text-center my-auto">{pluginEmpty()}</div>}
|
||||
>
|
||||
<For each={plugins()}>
|
||||
{(plugin) => (
|
||||
<div class="flex items-center gap-2 w-full px-2 py-1">
|
||||
<div class="size-1.5 rounded-full shrink-0 bg-icon-success-base" />
|
||||
<span class="text-14-regular text-text-base truncate">{plugin}</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
<Tabs.Content value="plugins">
|
||||
<div class="flex flex-col px-2 pb-2">
|
||||
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
|
||||
<Show
|
||||
when={plugins().length > 0}
|
||||
fallback={<div class="text-14-regular text-text-base text-center my-auto">{pluginEmpty()}</div>}
|
||||
>
|
||||
<For each={plugins()}>
|
||||
{(plugin) => (
|
||||
<div class="flex items-center gap-2 w-full px-2 py-1">
|
||||
<div class="size-1.5 rounded-full shrink-0 bg-icon-success-base" />
|
||||
<span class="text-14-regular text-text-base truncate">{plugin}</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
</Show>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Effect, Option } from "effect"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { createClient, loadIntegrations } from "./shared"
|
||||
import { errorMessage } from "../../../ui/prompt"
|
||||
import { errorMessage } from "../../../util/error"
|
||||
|
||||
export default Runtime.handler(Commands.commands.auth.commands.list, (input) =>
|
||||
list(input).pipe(
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { ServiceConfig } from "../../../services/service-config"
|
||||
import { createTimelineHost, type TimelineHost } from "../../../ui/timeline"
|
||||
import { errorMessage } from "../../../util/error"
|
||||
|
||||
const integrationID = "opencode"
|
||||
const location = { directory: process.cwd() }
|
||||
@@ -24,9 +25,9 @@ export default Runtime.handler(
|
||||
if (Exit.isSuccess(exit)) return
|
||||
|
||||
const cancelled = timeline.signal.aborted
|
||||
yield* request(() => timeline.failure(cancelled ? "Authorization cancelled" : errorMessage(exit.cause))).pipe(
|
||||
Effect.ignore,
|
||||
)
|
||||
yield* request(() =>
|
||||
timeline.failure(cancelled ? "Authorization cancelled" : errorMessage(Cause.squash(exit.cause))),
|
||||
).pipe(Effect.ignore)
|
||||
process.exitCode = cancelled ? 130 : 1
|
||||
}),
|
||||
)
|
||||
@@ -107,12 +108,3 @@ function request<A>(task: (signal: AbortSignal) => Promise<A>) {
|
||||
function required<A>(value: A | null | undefined, message: string) {
|
||||
return value === null || value === undefined ? Effect.fail(new Error(message)) : Effect.succeed(value)
|
||||
}
|
||||
|
||||
function errorMessage(cause: Cause.Cause<unknown>) {
|
||||
const error = Cause.squash(cause)
|
||||
if (error instanceof Error) return error.message
|
||||
if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string") {
|
||||
return error.message
|
||||
}
|
||||
return String(error)
|
||||
}
|
||||
|
||||
@@ -9,9 +9,7 @@ import { ServiceConfig } from "../../../services/service-config"
|
||||
export default Runtime.handler(
|
||||
Commands.commands.debug.commands.agents,
|
||||
Effect.fn("cli.debug.agents")(function* () {
|
||||
const options = yield* ServiceConfig.options()
|
||||
const found = yield* Service.discover(options)
|
||||
const endpoint = found ?? (yield* Service.ensure(options))
|
||||
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
const response = yield* Effect.promise(() => client.agent.list({ location: { directory: process.cwd() } }))
|
||||
process.stdout.write(
|
||||
|
||||
@@ -9,9 +9,7 @@ import { ServiceConfig } from "../../../services/service-config"
|
||||
export default Runtime.handler(
|
||||
Commands.commands.debug.commands.config,
|
||||
Effect.fn("cli.debug.config")(function* () {
|
||||
const options = yield* ServiceConfig.options()
|
||||
const found = yield* Service.discover(options)
|
||||
const endpoint = found ?? (yield* Service.ensure(options))
|
||||
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
const entries = yield* Effect.promise(() => client.config.get({ location: { directory: process.cwd() } }))
|
||||
process.stdout.write(JSON.stringify(entries, null, 2) + EOL)
|
||||
|
||||
@@ -6,7 +6,7 @@ import { EOL } from "node:os"
|
||||
import { Commands } from "../commands"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
import { ServerConnection } from "../../services/server-connection"
|
||||
import { errorMessage } from "../../ui/prompt"
|
||||
import { errorMessage } from "../../util/error"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.export,
|
||||
|
||||
@@ -17,9 +17,7 @@ const location = { directory: process.cwd() }
|
||||
export default Runtime.handler(
|
||||
Commands.commands.mcp.commands.auth,
|
||||
Effect.fn("cli.mcp.auth")(function* (input) {
|
||||
const options = yield* ServiceConfig.options()
|
||||
const found = yield* Service.discover(options)
|
||||
const endpoint = found ?? (yield* Service.ensure(options))
|
||||
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
|
||||
const integration = yield* resolveIntegration(client, input.name, location)
|
||||
|
||||
@@ -9,9 +9,7 @@ import { ServiceConfig } from "../../../services/service-config"
|
||||
export default Runtime.handler(
|
||||
Commands.commands.mcp.commands.list,
|
||||
Effect.fn("cli.mcp.list")(function* () {
|
||||
const options = yield* ServiceConfig.options()
|
||||
const found = yield* Service.discover(options)
|
||||
const endpoint = found ?? (yield* Service.ensure(options))
|
||||
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
const response = yield* Effect.promise(() => client.mcp.list({ location: { directory: process.cwd() } }))
|
||||
const servers = response.data.toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
|
||||
@@ -12,9 +12,7 @@ const location = { directory: process.cwd() }
|
||||
export default Runtime.handler(
|
||||
Commands.commands.mcp.commands.logout,
|
||||
Effect.fn("cli.mcp.logout")(function* (input) {
|
||||
const options = yield* ServiceConfig.options()
|
||||
const found = yield* Service.discover(options)
|
||||
const endpoint = found ?? (yield* Service.ensure(options))
|
||||
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
|
||||
const integration = yield* resolveIntegration(client, input.name, location)
|
||||
|
||||
@@ -12,9 +12,7 @@ import { discoverTuiPlugins, tuiPluginDirectories } from "@opencode-ai/tui/plugi
|
||||
export default Runtime.handler(
|
||||
Commands.commands.plugin.commands.list,
|
||||
Effect.fn("cli.plugin.list")(function* (input) {
|
||||
const options = yield* ServiceConfig.options()
|
||||
const found = yield* Service.discover(options)
|
||||
const endpoint = found ?? (yield* Service.ensure(options))
|
||||
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
const response = yield* Effect.promise(() => client.plugin.list({ location: { directory: process.cwd() } }))
|
||||
const config = yield* Config.Service
|
||||
|
||||
@@ -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"
|
||||
@@ -10,6 +11,7 @@ import { toolInlineInfo } from "@opencode-ai/tui/mini/tool"
|
||||
import { runNonInteractivePrompt } from "./noninteractive"
|
||||
import { UI } from "./ui"
|
||||
import { Env } from "../env"
|
||||
import { errorMessage } from "../util/error"
|
||||
|
||||
export type RunCommandInput = {
|
||||
server: ServerConnection.Resolved
|
||||
@@ -111,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)
|
||||
@@ -243,13 +250,6 @@ async function renderToolError(part: SessionMessageAssistantTool, directory: str
|
||||
UI.println(UI.Style.TEXT_NORMAL + "✗", UI.Style.TEXT_NORMAL + `${info.title} failed`)
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
if (error instanceof Error) return error.message
|
||||
if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string")
|
||||
return error.message
|
||||
return String(error)
|
||||
}
|
||||
|
||||
/** @internal Used by the V1 command boundary before a Session exists. */
|
||||
export function reportRunError(input: Pick<RunCommandInput, "format">, message: string, sessionID?: string) {
|
||||
process.exitCode = 1
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { cancel, isCancel, log, outro } from "@clack/prompts"
|
||||
import { Effect } from "effect"
|
||||
import { errorMessage } from "../util/error"
|
||||
|
||||
const cancelled = Symbol("cancelled")
|
||||
|
||||
@@ -38,11 +39,3 @@ export function handlePromptErrors<A, E, R>(effect: Effect.Effect<A, E, R>) {
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export function errorMessage(error: unknown) {
|
||||
if (error instanceof Error) return error.message
|
||||
if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string") {
|
||||
return error.message
|
||||
}
|
||||
return String(error)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export function errorMessage(error: unknown) {
|
||||
if (error instanceof Error) return error.message
|
||||
if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string") {
|
||||
return error.message
|
||||
}
|
||||
return String(error)
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -33,12 +33,14 @@ describe("debug config command", () => {
|
||||
},
|
||||
]
|
||||
let requested: URL | undefined
|
||||
let healthProbes = 0
|
||||
const authorization: Array<string | null> = []
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(request) {
|
||||
const url = new URL(request.url)
|
||||
if (url.pathname === "/api/health") {
|
||||
healthProbes += 1
|
||||
return Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid })
|
||||
}
|
||||
requested = url
|
||||
@@ -60,6 +62,7 @@ describe("debug config command", () => {
|
||||
expect(requested?.pathname).toBe("/api/config")
|
||||
expect(requested?.searchParams.get("location[directory]")).toBe(project)
|
||||
expect(authorization).toEqual([`Basic ${btoa("opencode:secret")}`])
|
||||
expect(healthProbes).toBe(1)
|
||||
} finally {
|
||||
server.stop(true)
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
|
||||
@@ -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,9 @@ describe("CLI frontend import boundaries", () => {
|
||||
test("keeps run and Mini on separate evaluation graphs", async () => {
|
||||
const run = await bundleInputs("packages/cli/src/commands/handlers/run.ts")
|
||||
expect(run).toContain("packages/cli/src/run/run.ts")
|
||||
expect(run).toContain("packages/cli/src/util/error.ts")
|
||||
expect(run).toContain("packages/tui/src/mini/tool.ts")
|
||||
expect(run).not.toContain("packages/cli/src/ui/prompt.ts")
|
||||
expect(run).not.toContain("packages/tui/src/mini/runtime.ts")
|
||||
expect(run).not.toContain("packages/tui/src/mini/runtime.lifecycle.ts")
|
||||
expect(run).not.toContain("packages/tui/src/mini/footer.ts")
|
||||
|
||||
@@ -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)}`,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,10 @@ export type Info = import("../service.js").Info
|
||||
// Never spawns; escalation to ensure() is the caller's policy.
|
||||
/** Discover a healthy, compatible local service without starting one. */
|
||||
export const discover = Effect.fn("service.discover")(function* (options: DiscoverOptions = {}) {
|
||||
return (yield* discoverLocal(options))?.endpoint
|
||||
const found = (yield* registered(options.file)).service
|
||||
if (found?.state !== "ready") return undefined
|
||||
if (!matchesVersion(found.version, options)) return undefined
|
||||
return found.endpoint
|
||||
})
|
||||
|
||||
/** Recognize an authenticated compatible service bound to an expected URL, including while it starts or fails. */
|
||||
@@ -42,13 +45,6 @@ export const incumbent = Effect.fn("service.incumbent")(function* (
|
||||
return { endpoint: found.endpoint, state: found.state }
|
||||
})
|
||||
|
||||
const discoverLocal = Effect.fnUntraced(function* (options: DiscoverOptions) {
|
||||
const found = (yield* registered(options.file)).service
|
||||
if (found?.state !== "ready") return undefined
|
||||
if (!matchesVersion(found.version, options)) return undefined
|
||||
return found
|
||||
})
|
||||
|
||||
// Idempotent ensure-running: reuses a healthy compatible server, replaces a
|
||||
// version-mismatched one, and otherwise spawns small contenders until a server
|
||||
// becomes discoverable. A contender is never killed merely for slow startup.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -22,14 +22,10 @@ export * from "../service.js"
|
||||
|
||||
/** Discover a healthy, compatible local service without starting one. */
|
||||
export async function discover(options: DiscoverOptions = {}) {
|
||||
return (await discoverLocal(options))?.endpoint
|
||||
}
|
||||
|
||||
async function discoverLocal(options: DiscoverOptions) {
|
||||
const found = (await registered(options.file)).service
|
||||
if (found?.state !== "ready") return undefined
|
||||
if (!matchesVersion(found.version, options)) return undefined
|
||||
return found
|
||||
return found.endpoint
|
||||
}
|
||||
|
||||
/** Ensure a healthy, compatible local service is running. */
|
||||
@@ -144,10 +140,6 @@ type LocalService = {
|
||||
readonly legacy: boolean
|
||||
}
|
||||
|
||||
async function probe(info: Info, allowLegacy = false): Promise<LocalService | undefined> {
|
||||
return (await probeResult(info, allowLegacy)).service
|
||||
}
|
||||
|
||||
async function probeResult(info: Info, allowLegacy = false, timeout = defaultEnsureTiming.requestTimeout) {
|
||||
const endpoint = {
|
||||
url: info.url,
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
McpResource,
|
||||
McpServer,
|
||||
ModelInfo,
|
||||
ModelRef,
|
||||
PermissionSavedInfo,
|
||||
PermissionRequest,
|
||||
PermissionReplyInput,
|
||||
@@ -33,7 +34,10 @@ 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"
|
||||
import { isPermissionNotFoundError, type SessionPromptInput } from "../promise"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
@@ -57,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.
|
||||
@@ -226,6 +228,33 @@ export function createData(config: CreateDataInput) {
|
||||
// rollback — not on POST success, which typically precedes the echo.
|
||||
const outbox = new Set<string>()
|
||||
|
||||
// Session IDs of optimistic create admissions still awaiting acknowledgement
|
||||
// (the session.created echo or the create response itself). A failed create
|
||||
// only rolls back a session the server never acknowledged. Unlike
|
||||
// `creating`, this clears on the echo rather than request settlement.
|
||||
const sessionOutbox = new Set<string>()
|
||||
|
||||
// In-flight optimistic creates by session ID. prompt() gates its POST on
|
||||
// this so a prompt sent to a still-creating session waits for the session
|
||||
// to exist server-side instead of failing with "not found".
|
||||
const creating = new Map<string, Promise<unknown>>()
|
||||
|
||||
// Per-session send chain: prompts must be admitted in submission order,
|
||||
// and HTTP gives no ordering across concurrent POSTs. Each prompt waits
|
||||
// for the previous prompt's POST (settled, so one failure does not block
|
||||
// the next) before sending its own.
|
||||
const sending = new Map<string, Promise<unknown>>()
|
||||
|
||||
// Register `promise` under `key` until it settles. A later registration
|
||||
// replaces an earlier one; settlement only clears its own entry.
|
||||
function track(map: Map<string, Promise<unknown>>, key: string, promise: Promise<unknown>) {
|
||||
map.set(key, promise)
|
||||
const settle = () => {
|
||||
if (map.get(key) === promise) map.delete(key)
|
||||
}
|
||||
void promise.then(settle, settle)
|
||||
}
|
||||
|
||||
// Upsert an admitted inbox item into pending, input, and (for user and
|
||||
// synthetic items) the visible transcript. Used by the inbox.enqueued
|
||||
// handler and by optimistic prompt admission; the upsert is what reconciles
|
||||
@@ -385,6 +414,7 @@ export function createData(config: CreateDataInput) {
|
||||
store.session.pending[sessionID]?.forEach((item) => outbox.delete(item.id))
|
||||
messageIndex.delete(sessionID)
|
||||
sync.invalidate(`session:${sessionID}`)
|
||||
sync.invalidate(`session.family:${sessionID}`)
|
||||
sync.invalidate(`session.pending:${sessionID}`)
|
||||
sync.invalidate(`session.message:${sessionID}`)
|
||||
sync.invalidate(`session.permission:${sessionID}`)
|
||||
@@ -434,6 +464,7 @@ export function createData(config: CreateDataInput) {
|
||||
void result.project.sync().catch((error) => console.error("Failed to preload projects", error))
|
||||
return
|
||||
case "session.created":
|
||||
sessionOutbox.delete(event.data.sessionID)
|
||||
result.session.invalidate(event.data.sessionID)
|
||||
void result.session.sync(event.data.sessionID)
|
||||
// Band-aid: a newly created session starts empty, so live events can be its source of truth.
|
||||
@@ -459,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,
|
||||
@@ -474,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)
|
||||
@@ -511,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,
|
||||
@@ -571,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(", ")}`,
|
||||
@@ -583,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,
|
||||
@@ -595,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,
|
||||
@@ -833,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,
|
||||
@@ -896,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,
|
||||
@@ -912,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",
|
||||
@@ -1110,46 +1141,117 @@ export function createData(config: CreateDataInput) {
|
||||
sync.invalidate(`session.pending:${sessionID}`)
|
||||
},
|
||||
},
|
||||
// Optimistic session creation: admit a local record under a
|
||||
// client-minted ID so a session view can mount immediately, then create
|
||||
// the session on the server. The session.created echo re-syncs the
|
||||
// record by ID, so the durable payload replaces the client's guess.
|
||||
// Returns the ID synchronously along with the in-flight request:
|
||||
// callers gate session-dependent sends on the request (prompt() gates
|
||||
// itself on any in-flight create of its session automatically).
|
||||
create(input: {
|
||||
id?: string
|
||||
title?: string
|
||||
agent?: string
|
||||
model?: ModelRef
|
||||
location?: LocationRef
|
||||
projectID?: string
|
||||
}) {
|
||||
const { projectID, ...payload } = input
|
||||
const id = payload.id ?? SessionID.create()
|
||||
const location = payload.location ?? defaultLocation()
|
||||
const fresh = !store.session.info[id]
|
||||
if (fresh) {
|
||||
const now = Date.now()
|
||||
sessionOutbox.add(id)
|
||||
result.session.remember({
|
||||
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 } },
|
||||
time: { created: now, updated: now },
|
||||
title: payload.title,
|
||||
location,
|
||||
})
|
||||
// A mounted optimistic session must not fetch its empty collections
|
||||
// before creation settles. The session.created echo re-syncs info.
|
||||
sync.complete(`session.family:${id}`)
|
||||
sync.complete(`session.pending:${id}`)
|
||||
sync.complete(`session.message:${id}`)
|
||||
}
|
||||
// Wrapped so even a synchronous client failure reaches the rollback.
|
||||
const request = Promise.resolve()
|
||||
.then(() => api().session.create({ ...payload, id, location }))
|
||||
.then((info) => {
|
||||
sessionOutbox.delete(id)
|
||||
result.session.remember(info)
|
||||
return info
|
||||
})
|
||||
.catch((error) => {
|
||||
// Roll back only a record this call admitted and neither the echo
|
||||
// nor the response has acknowledged: anything else is server state.
|
||||
if (fresh && sessionOutbox.delete(id)) removeSession(id)
|
||||
throw error
|
||||
})
|
||||
if (fresh) track(creating, id, request)
|
||||
return { id, request }
|
||||
},
|
||||
// Optimistic prompt admission: render the prompt immediately under a
|
||||
// client-minted ID, send it, and let the durable inbox.enqueued echo
|
||||
// upsert that same ID with the server's payload. Server admission is
|
||||
// idempotent per ID, so retrying with the identical payload cannot
|
||||
// double-admit.
|
||||
prompt(input: SessionPromptInput) {
|
||||
const id = input.id ?? SessionMessage.ID.create()
|
||||
prompt(input: SessionPromptInput & { gate?: Promise<unknown> }) {
|
||||
const { gate, ...request } = input
|
||||
const id = request.id ?? SessionMessage.ID.create()
|
||||
// A retry may reuse an ID that is already rendered — and possibly
|
||||
// already durable. Admit optimistically only for new IDs so a failed
|
||||
// retry cannot roll back acknowledged state.
|
||||
const fresh =
|
||||
!messageIndex.get(input.sessionID)?.has(id) &&
|
||||
!store.session.pending[input.sessionID]?.some((item) => item.id === id)
|
||||
!messageIndex.get(request.sessionID)?.has(id) &&
|
||||
!store.session.pending[request.sessionID]?.some((item) => item.id === id)
|
||||
if (fresh) {
|
||||
outbox.add(id)
|
||||
admitLocal({
|
||||
id,
|
||||
sessionID: input.sessionID,
|
||||
id: SessionMessage.ID.make(id),
|
||||
sessionID: SessionID.make(request.sessionID),
|
||||
timeCreated: Date.now(),
|
||||
type: "user",
|
||||
delivery: input.delivery ?? "steer",
|
||||
delivery: request.delivery ?? "steer",
|
||||
// Files and skills stay off the optimistic row: their durable
|
||||
// forms are server-loaded (content, mime, resolution), so they
|
||||
// fill in when the echo upserts the row.
|
||||
payload: {
|
||||
text: input.text,
|
||||
agents: input.agents?.map((agent) => ({ ...agent })),
|
||||
metadata: input.metadata,
|
||||
text: request.text,
|
||||
agents: request.agents?.map((agent) => ({ ...agent })),
|
||||
metadata: request.metadata,
|
||||
},
|
||||
})
|
||||
}
|
||||
// Wrapped so even a synchronous client failure reaches the rollback.
|
||||
return Promise.resolve()
|
||||
.then(() => api().session.prompt({ ...input, id }))
|
||||
.catch((error) => {
|
||||
// Roll back only rows this call admitted and the echo has not
|
||||
// acknowledged: anything else is server state.
|
||||
if (fresh && outbox.delete(id)) retractLocal(input.sessionID, id)
|
||||
throw error
|
||||
})
|
||||
// The POST additionally waits for the caller's gate, for any
|
||||
// in-flight optimistic create of this session, and for the previous
|
||||
// prompt's POST: the row renders now, the send happens once the
|
||||
// session exists server-side and earlier prompts are admitted.
|
||||
const previous = sending.get(request.sessionID)
|
||||
const send = Promise.resolve()
|
||||
.then(() => Promise.all([gate, creating.get(request.sessionID), previous]))
|
||||
.then(() => api().session.prompt({ ...request, id }))
|
||||
track(
|
||||
sending,
|
||||
request.sessionID,
|
||||
send.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
),
|
||||
)
|
||||
return send.catch((error) => {
|
||||
// Roll back only rows this call admitted and the echo has not
|
||||
// acknowledged: anything else is server state.
|
||||
if (fresh && outbox.delete(id)) retractLocal(request.sessionID, id)
|
||||
throw error
|
||||
})
|
||||
},
|
||||
sync(sessionID: string, options?: { children?: boolean }) {
|
||||
return sync.run(options?.children ? `session.family:${sessionID}` : `session:${sessionID}`, async () => {
|
||||
|
||||
@@ -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" })
|
||||
|
||||
@@ -127,7 +127,7 @@ function prepareOptions(model: Info, pkg: string) {
|
||||
options.timeout !== undefined && options.timeout !== null && options.timeout !== false
|
||||
? AbortSignal.timeout(options.timeout)
|
||||
: undefined,
|
||||
].filter((item): item is AbortSignal | AbortController => Boolean(item))
|
||||
].filter((item): item is AbortSignal | AbortController => item !== undefined && item !== null)
|
||||
const chunkAbortCtl = signals.find((item): item is AbortController => item instanceof AbortController)
|
||||
const abortSignals = signals.map((item) => (item instanceof AbortController ? item.signal : item))
|
||||
if (abortSignals.length === 1) opts.signal = abortSignals[0]
|
||||
@@ -346,8 +346,7 @@ function gatewayProviderOptions(modelID: ID, settings: Readonly<Record<string, u
|
||||
const prefix = separator > 0 ? modelID.slice(0, separator) : undefined
|
||||
if (prefix)
|
||||
return { ...(gateway === undefined ? {} : { gateway }), [prefix === "amazon" ? "bedrock" : prefix]: model }
|
||||
if (typeof gateway === "object" && gateway !== null && !Array.isArray(gateway))
|
||||
return { gateway: { ...gateway, ...model } }
|
||||
if (gateway !== undefined) return { gateway: { ...gateway, ...model } }
|
||||
return { gateway: model }
|
||||
}
|
||||
|
||||
|
||||
+30
-32
@@ -227,7 +227,7 @@ export function configured(options?: Options) {
|
||||
commit?: (seq: number) => Effect.Effect<void>,
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
const durable = definition?.durable
|
||||
const durable = definition.durable
|
||||
if (durable) {
|
||||
const aggregateID = (event.data as Record<string, unknown>)[durable.aggregate]
|
||||
if (typeof aggregateID !== "string") {
|
||||
@@ -391,14 +391,14 @@ export function configured(options?: Options) {
|
||||
commit?: PublishOptions["commit"],
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
if (!definition?.durable && commit)
|
||||
if (!definition.durable && commit)
|
||||
return yield* Effect.die(
|
||||
new InvalidDurableEventError({
|
||||
type: event.type,
|
||||
message: "Local commit hooks require a durable event",
|
||||
}),
|
||||
)
|
||||
if (definition?.durable) {
|
||||
if (definition.durable) {
|
||||
const aggregateID = (event.data as Record<string, unknown>)[definition.durable.aggregate]
|
||||
if (typeof aggregateID !== "string")
|
||||
return yield* commitDurableEvent(definition, event as Event.Payload, undefined, commit).pipe(
|
||||
@@ -610,37 +610,35 @@ export function configured(options?: Options) {
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
const definition = Durable.get(event.type)
|
||||
if (!definition?.durable) {
|
||||
yield* Effect.die(
|
||||
if (!definition?.durable)
|
||||
return yield* Effect.die(
|
||||
new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` }),
|
||||
)
|
||||
} else {
|
||||
yield* durableLocks.withLock(event.aggregateID)(
|
||||
Effect.gen(function* () {
|
||||
const payload = {
|
||||
id: event.id,
|
||||
created: event.created ?? 0,
|
||||
type: definition.type,
|
||||
data: Schema.decodeUnknownSync(definition.data)(event.data),
|
||||
} as Event.Payload
|
||||
const committed = yield* commitDurableEvent(definition, payload, {
|
||||
seq: event.seq,
|
||||
aggregateID: event.aggregateID,
|
||||
ownerID: options?.ownerID,
|
||||
strictOwner: options?.strictOwner,
|
||||
})
|
||||
if (committed && options?.publish) {
|
||||
yield* notify(
|
||||
{
|
||||
...payload,
|
||||
durable: envelope(committed.aggregateID, committed.seq, definition.durable.version),
|
||||
},
|
||||
true,
|
||||
)
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
yield* durableLocks.withLock(event.aggregateID)(
|
||||
Effect.gen(function* () {
|
||||
const payload = {
|
||||
id: event.id,
|
||||
created: event.created ?? 0,
|
||||
type: definition.type,
|
||||
data: Schema.decodeUnknownSync(definition.data)(event.data),
|
||||
} as Event.Payload
|
||||
const committed = yield* commitDurableEvent(definition, payload, {
|
||||
seq: event.seq,
|
||||
aggregateID: event.aggregateID,
|
||||
ownerID: options?.ownerID,
|
||||
strictOwner: options?.strictOwner,
|
||||
})
|
||||
if (committed && options?.publish) {
|
||||
yield* notify(
|
||||
{
|
||||
...payload,
|
||||
durable: envelope(committed.aggregateID, committed.seq, definition.durable.version),
|
||||
},
|
||||
true,
|
||||
)
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -134,7 +134,7 @@ const layer = Layer.effect(
|
||||
}
|
||||
return result
|
||||
},
|
||||
finalize: Effect.fn("Catalog.finalize")(function* (catalog) {
|
||||
finalize: Effect.fn("Catalog.finalize")(function* () {
|
||||
yield* bus.publish(Catalog.Event.Updated, {})
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -112,15 +112,15 @@ export const create = (
|
||||
files: collected,
|
||||
...(result.ok ? {} : { error: true }),
|
||||
}
|
||||
const content: Array<Content> = [{ type: "text", text: value.output }]
|
||||
content.push(
|
||||
const content: Array<Content> = [
|
||||
{ type: "text", text: value.output },
|
||||
...value.files.map((file) => ({
|
||||
type: "file" as const,
|
||||
uri: `data:${file.mime};base64,${file.data}`,
|
||||
mime: file.mime,
|
||||
...(file.name === undefined ? {} : { name: file.name }),
|
||||
})),
|
||||
)
|
||||
]
|
||||
const metadata: Metadata = {
|
||||
toolCalls: value.toolCalls,
|
||||
...(value.error ? { error: true } : {}),
|
||||
|
||||
+40
-49
@@ -17,6 +17,7 @@ import {
|
||||
Event,
|
||||
} from "@opencode-ai/schema/config"
|
||||
import { Integration } from "@opencode-ai/schema/integration"
|
||||
import { isRecord } from "@opencode-ai/ai/utils/record"
|
||||
import { Credential } from "./credential.js"
|
||||
import { Bus } from "./bus.js"
|
||||
import { Watcher } from "./filesystem/watcher.js"
|
||||
@@ -29,9 +30,8 @@ import { ConfigNormalize } from "./config/normalize.js"
|
||||
import { WellKnown } from "./wellknown.js"
|
||||
|
||||
export function latest<K extends keyof Info>(entries: readonly Entry[], key: K): Info[K] | undefined {
|
||||
return entries
|
||||
.filter((entry): entry is Document => entry.type === "document")
|
||||
.findLast((entry) => entry.info[key] !== undefined)?.info[key]
|
||||
return entries.findLast((entry): entry is Document => entry.type === "document" && entry.info[key] !== undefined)
|
||||
?.info[key]
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
@@ -156,6 +156,37 @@ export const layer = (options?: Options) =>
|
||||
return new Document({ type: "document", path: AbsolutePath.make(filepath), info })
|
||||
})
|
||||
|
||||
const loadWellknownEntry = Effect.fnUntraced(function* (entry: WellKnown.Entry) {
|
||||
const auth = entry.manifest.auth
|
||||
if (!auth) return []
|
||||
const credential = (yield* credentials.list(entry.integrationID)).findLast(
|
||||
(credential) => credential.value.type === "key",
|
||||
)
|
||||
if (!credential || credential.value.type !== "key") return []
|
||||
const variables = { [auth.env]: credential.value.key }
|
||||
const configs = yield* wellknown
|
||||
.resolve(entry, variables)
|
||||
.pipe(
|
||||
Effect.catch(() =>
|
||||
Effect.logWarning("failed to load wellknown config", { source: entry.origin }).pipe(
|
||||
Effect.as([] as const),
|
||||
),
|
||||
),
|
||||
)
|
||||
return yield* Effect.forEach(configs, (config) =>
|
||||
ConfigVariable.substitute({
|
||||
type: "virtual",
|
||||
source: entry.origin,
|
||||
dir: entry.origin,
|
||||
text: JSON.stringify(config),
|
||||
env: variables,
|
||||
}).pipe(
|
||||
Effect.flatMap((text) => parseInfo(text, entry.origin)),
|
||||
Effect.map((info) => (info ? new Document({ type: "document", info }) : undefined)),
|
||||
),
|
||||
).pipe(Effect.map((documents) => documents.filter((document) => document !== undefined)))
|
||||
})
|
||||
|
||||
const loadWellknown = Effect.fn("Config.loadWellknown")(function* () {
|
||||
const entries = yield* wellknown
|
||||
.entries()
|
||||
@@ -164,38 +195,7 @@ export const layer = (options?: Options) =>
|
||||
Effect.logWarning("failed to discover wellknown config", { error }).pipe(Effect.as([] as const)),
|
||||
),
|
||||
)
|
||||
return yield* Effect.forEach(entries, (entry) =>
|
||||
Effect.gen(function* () {
|
||||
const auth = entry.manifest.auth
|
||||
if (!auth) return []
|
||||
const credential = (yield* credentials.list(entry.integrationID)).findLast(
|
||||
(credential) => credential.value.type === "key",
|
||||
)
|
||||
if (!credential || credential.value.type !== "key") return []
|
||||
const variables = { [auth.env]: credential.value.key }
|
||||
const configs = yield* wellknown
|
||||
.resolve(entry, variables)
|
||||
.pipe(
|
||||
Effect.catch(() =>
|
||||
Effect.logWarning("failed to load wellknown config", { source: entry.origin }).pipe(
|
||||
Effect.as([] as const),
|
||||
),
|
||||
),
|
||||
)
|
||||
return yield* Effect.forEach(configs, (config) =>
|
||||
ConfigVariable.substitute({
|
||||
type: "virtual",
|
||||
source: entry.origin,
|
||||
dir: entry.origin,
|
||||
text: JSON.stringify(config),
|
||||
env: variables,
|
||||
}).pipe(
|
||||
Effect.flatMap((text) => parseInfo(text, entry.origin)),
|
||||
Effect.map((info) => (info ? new Document({ type: "document", info }) : undefined)),
|
||||
),
|
||||
).pipe(Effect.map((documents) => documents.filter((document) => document !== undefined)))
|
||||
}),
|
||||
).pipe(Effect.map((documents) => documents.flat()))
|
||||
return yield* Effect.forEach(entries, loadWellknownEntry).pipe(Effect.map((documents) => documents.flat()))
|
||||
})
|
||||
|
||||
const loadDirectory = Effect.fnUntraced(function* (directory: AbsolutePath) {
|
||||
@@ -449,20 +449,11 @@ type Edit = { readonly path: (string | number)[]; readonly value: unknown }
|
||||
|
||||
function changes(before: unknown, after: unknown, path: (string | number)[] = []): Edit[] {
|
||||
if (Object.is(before, after)) return []
|
||||
if (
|
||||
before !== null &&
|
||||
after !== null &&
|
||||
typeof before === "object" &&
|
||||
typeof after === "object" &&
|
||||
!Array.isArray(before) &&
|
||||
!Array.isArray(after)
|
||||
) {
|
||||
const previous = before as Record<string, unknown>
|
||||
const next = after as Record<string, unknown>
|
||||
return [...new Set([...Object.keys(previous), ...Object.keys(next)])].flatMap((key) => {
|
||||
if (!(key in next)) return [{ path: [...path, key], value: undefined }]
|
||||
if (!(key in previous)) return [{ path: [...path, key], value: next[key] }]
|
||||
return changes(previous[key], next[key], [...path, key])
|
||||
if (isRecord(before) && isRecord(after)) {
|
||||
return [...new Set([...Object.keys(before), ...Object.keys(after)])].flatMap((key) => {
|
||||
if (!(key in after)) return [{ path: [...path, key], value: undefined }]
|
||||
if (!(key in before)) return [{ path: [...path, key], value: after[key] }]
|
||||
return changes(before[key], after[key], [...path, key])
|
||||
})
|
||||
}
|
||||
return [{ path, value: after }]
|
||||
|
||||
@@ -793,7 +793,7 @@ function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||
}
|
||||
|
||||
function own(value: Record<string, unknown>, key: string) {
|
||||
return Object.prototype.hasOwnProperty.call(value, key)
|
||||
return Object.hasOwn(value, key)
|
||||
}
|
||||
|
||||
function setOwn(value: Record<string, unknown>, key: string, item: unknown) {
|
||||
|
||||
@@ -52,22 +52,19 @@ export const Plugin = define({
|
||||
const config = yield* Config.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const global = yield* Global.Service
|
||||
const loadEntry = Effect.fnUntraced(function* (entry: Entry) {
|
||||
if (entry.type === "document") return [entry]
|
||||
if (entry.type !== "directory") return []
|
||||
const files = yield* discover(fs, entry.path)
|
||||
return yield* Effect.forEach(files, (file) =>
|
||||
fs.readFileStringSafe(file.filepath).pipe(
|
||||
Effect.map((content) => (content ? decode(file, content) : undefined)),
|
||||
Effect.catch(() => Effect.succeed(undefined)),
|
||||
),
|
||||
).pipe(Effect.map((documents) => documents.filter((document): document is Document => document !== undefined)))
|
||||
})
|
||||
const load = Effect.fn("ConfigAgentPlugin.load")(function* () {
|
||||
return yield* Effect.forEach(yield* config.entries(), (entry) => {
|
||||
if (entry.type === "document") return Effect.succeed([entry])
|
||||
if (entry.type !== "directory") return Effect.succeed([])
|
||||
return Effect.gen(function* () {
|
||||
const files = yield* discover(fs, entry.path)
|
||||
return yield* Effect.forEach(files, (file) =>
|
||||
fs.readFileStringSafe(file.filepath).pipe(
|
||||
Effect.map((content) => (content ? decode(file, content) : undefined)),
|
||||
Effect.catch(() => Effect.succeed(undefined)),
|
||||
),
|
||||
).pipe(
|
||||
Effect.map((documents) => documents.filter((document): document is Document => document !== undefined)),
|
||||
)
|
||||
})
|
||||
}).pipe(Effect.map((documents) => documents.flat()))
|
||||
return yield* Effect.forEach(yield* config.entries(), loadEntry).pipe(Effect.map((documents) => documents.flat()))
|
||||
})
|
||||
const loaded = { documents: [] as Document[] }
|
||||
const reload = load().pipe(
|
||||
@@ -160,8 +157,7 @@ function isPathAction(action: string): action is PathAction {
|
||||
}
|
||||
|
||||
function expandHome(resource: string, home: string) {
|
||||
if (resource === "~") return home
|
||||
if (resource === "$HOME") return home
|
||||
if (resource === "~" || resource === "$HOME") return home
|
||||
const relative = resource.startsWith("~/")
|
||||
? resource.slice(2)
|
||||
: resource.startsWith("$HOME/") || resource.startsWith("$HOME\\")
|
||||
|
||||
@@ -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"
|
||||
@@ -17,16 +18,14 @@ export const Plugin = define({
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const config = yield* Config.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const loadEntry = Effect.fnUntraced(function* (entry: Entry) {
|
||||
if (entry.type === "document") return [{ commands: entry.info.commands }]
|
||||
if (entry.type !== "directory") return []
|
||||
const commands = yield* loadDirectory(fs, entry.path)
|
||||
return [{ commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) }]
|
||||
})
|
||||
const load = Effect.fn("ConfigCommandPlugin.load")(function* () {
|
||||
return yield* Effect.forEach(yield* config.entries(), (entry) => {
|
||||
if (entry.type === "document") return Effect.succeed([{ commands: entry.info.commands }])
|
||||
if (entry.type !== "directory") return Effect.succeed([])
|
||||
return loadDirectory(fs, entry.path).pipe(
|
||||
Effect.map((commands) => [
|
||||
{ commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) },
|
||||
]),
|
||||
)
|
||||
}).pipe(Effect.map((documents) => documents.flat()))
|
||||
return yield* Effect.forEach(yield* config.entries(), loadEntry).pipe(Effect.map((documents) => documents.flat()))
|
||||
})
|
||||
const loaded = { documents: [] as { commands: Info["commands"] }[] }
|
||||
const reload = load().pipe(
|
||||
@@ -56,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,
|
||||
|
||||
@@ -1,35 +1,24 @@
|
||||
export * as ConfigCompactionPlugin from "./compaction.js"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Effect } from "effect"
|
||||
import { Config } from "../../config.js"
|
||||
import { SessionCompaction } from "../../session/compaction.js"
|
||||
import { ConfigEntryObserver } from "./entry-observer.js"
|
||||
|
||||
export const Plugin = define({
|
||||
id: "opencode.config.compaction",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const config = yield* Config.Service
|
||||
const compaction = yield* SessionCompaction.Service
|
||||
const loaded = { entries: yield* config.entries() }
|
||||
const reload = config.entries().pipe(
|
||||
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
|
||||
Effect.andThen(compaction.reload()),
|
||||
)
|
||||
yield* ctx.event.subscribe().pipe(
|
||||
Stream.filter((event) => event.type === "config.updated"),
|
||||
Stream.runForEach(() => reload),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
loaded.entries = yield* config.entries()
|
||||
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, compaction.reload())
|
||||
yield* compaction.transform((draft) => {
|
||||
for (const entry of loaded.entries) {
|
||||
if (entry.type !== "document" || !entry.info.compaction) continue
|
||||
draft.configure({
|
||||
...(entry.info.compaction.auto === undefined ? {} : { auto: entry.info.compaction.auto }),
|
||||
...(entry.info.compaction.buffer === undefined ? {} : { buffer: entry.info.compaction.buffer }),
|
||||
...(entry.info.compaction.keep?.tokens === undefined
|
||||
? {}
|
||||
: { tokens: entry.info.compaction.keep.tokens }),
|
||||
...(entry.info.compaction.keep?.tokens === undefined ? {} : { tokens: entry.info.compaction.keep.tokens }),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
export * as ConfigEntryObserver from "./entry-observer.js"
|
||||
|
||||
import type { EventDomain } from "@opencode-ai/plugin/effect/event"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Config } from "../../config.js"
|
||||
|
||||
export const observe = Effect.fnUntraced(function* (
|
||||
config: Config.Interface,
|
||||
event: EventDomain,
|
||||
reload: Effect.Effect<void>,
|
||||
) {
|
||||
const loaded = { entries: yield* config.entries() }
|
||||
const refresh = config.entries().pipe(
|
||||
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
|
||||
Effect.andThen(reload),
|
||||
)
|
||||
yield* event.subscribe().pipe(
|
||||
Stream.filter((event) => event.type === "config.updated"),
|
||||
Stream.runForEach(() => refresh),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
// Close the race between the first read and establishing the subscription.
|
||||
loaded.entries = yield* config.entries()
|
||||
return loaded
|
||||
})
|
||||
@@ -5,11 +5,12 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Effect } from "effect"
|
||||
import { Config } from "../../config.js"
|
||||
import { Formatter } from "../../formatter.js"
|
||||
import { make, type Info } from "../../formatter/builtins.js"
|
||||
import { Location } from "../../location.js"
|
||||
import { ConfigEntryObserver } from "./entry-observer.js"
|
||||
|
||||
export const Plugin = define({
|
||||
id: "opencode.config.formatter",
|
||||
@@ -21,20 +22,7 @@ export const Plugin = define({
|
||||
const location = yield* Location.Service
|
||||
const npm = yield* Npm.Service
|
||||
const processes = yield* AppProcess.Service
|
||||
const loaded = { entries: yield* config.entries() }
|
||||
const reload = config.entries().pipe(
|
||||
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
|
||||
Effect.andThen(formatter.reload()),
|
||||
)
|
||||
|
||||
yield* ctx.event.subscribe().pipe(
|
||||
Stream.filter((event) => event.type === "config.updated"),
|
||||
Stream.runForEach(() => reload),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
// Refetch after subscribing so a config update between the first read and
|
||||
// the live subscription cannot leave the transform on a stale snapshot.
|
||||
loaded.entries = yield* config.entries()
|
||||
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, formatter.reload())
|
||||
|
||||
yield* formatter.transform((draft) => {
|
||||
const configured = Config.latest(loaded.entries, "formatter")
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user