Merge remote-tracking branch 'origin/dev' into refactor/tui-v2-api

# Conflicts:
#	packages/server/src/groups/session.ts
#	packages/tui/src/component/prompt/autocomplete.tsx
#	packages/tui/src/context/data.tsx
#	packages/tui/test/cli/tui/data.test.tsx
This commit is contained in:
Dax Raad
2026-06-10 23:35:51 -04:00
6 changed files with 487 additions and 76 deletions
@@ -1,6 +1,6 @@
import { NodeFileSystem } from "@effect/platform-node"
import { SessionV1 } from "@opencode-ai/core/v1/session"
import { Database } from "@opencode-ai/core/database/database"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2Bridge } from "@/event-v2-bridge"
import { expect } from "bun:test"
import { tool } from "ai"
@@ -8,11 +8,6 @@ import { Cause, Effect, Exit, Fiber, Layer, Stream } from "effect"
import path from "path"
import z from "zod"
import type { Agent } from "../../src/agent/agent"
import { Agent as AgentSvc } from "../../src/agent/agent"
import { Config } from "@/config/config"
import { Image } from "@/image/image"
import { Permission } from "../../src/permission"
import { Plugin } from "../../src/plugin"
import { Provider } from "@/provider/provider"
import { Session } from "@/session/session"
@@ -22,7 +17,6 @@ import { SessionProcessor } from "../../src/session/processor"
import { MessageID, PartID, SessionID } from "../../src/session/schema"
import { SessionStatus } from "../../src/session/status"
import { SessionSummary } from "../../src/session/summary"
import { Snapshot } from "../../src/snapshot"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { provideTmpdirInstance, provideTmpdirServer } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
@@ -31,6 +25,7 @@ import { RuntimeFlags } from "@/effect/runtime-flags"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { LLMEvent } from "@opencode-ai/llm"
const summary = Layer.succeed(
@@ -171,30 +166,21 @@ const assistant = Effect.fn("TestSession.assistant")(function* (
return msg
})
const status = SessionStatus.layer.pipe(Layer.provideMerge(EventV2Bridge.defaultLayer))
const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer)
const deps = Layer.mergeAll(
Session.defaultLayer,
Snapshot.defaultLayer,
AgentSvc.defaultLayer,
Permission.defaultLayer,
Plugin.defaultLayer,
Config.defaultLayer,
LLM.defaultLayer,
Provider.defaultLayer,
status,
Database.defaultLayer,
EventV2Bridge.defaultLayer,
).pipe(Layer.provideMerge(infra))
const env = Layer.mergeAll(
TestLLMServer.layer,
SessionProcessor.layer.pipe(
Layer.provide(summary),
Layer.provide(Image.defaultLayer),
Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })),
Layer.provideMerge(deps),
),
)
const root = LayerNode.group([
SessionProcessor.node,
Session.node,
SessionProjector.node,
Provider.node,
Database.node,
EventV2Bridge.node,
SessionStatus.node,
CrossSpawnSpawner.node,
])
const replacements = [
LayerNode.replace(SessionSummary.node, summary),
LayerNode.replace(RuntimeFlags.node, RuntimeFlags.layer({ experimentalEventSystem: true })),
]
const env = LayerNode.buildLayer(LayerNode.group([root, LayerNode.make(TestLLMServer.layer, [])]), { replacements })
const it = testEffect(env)
@@ -218,13 +204,9 @@ const providerErrorLLM = Layer.succeed(
),
}),
)
const providerErrorEnv = SessionProcessor.layer.pipe(
Layer.provide(summary),
Layer.provide(Image.defaultLayer),
Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })),
Layer.provide(providerErrorLLM),
Layer.provideMerge(deps),
)
const providerErrorEnv = LayerNode.buildLayer(root, {
replacements: [...replacements, LayerNode.replace(LLM.node, providerErrorLLM)],
})
const itProviderError = testEffect(providerErrorEnv)
const fragmentFailureLLM = Layer.succeed(
@@ -241,13 +223,9 @@ const fragmentFailureLLM = Layer.succeed(
),
}),
)
const fragmentFailureEnv = SessionProcessor.layer.pipe(
Layer.provide(summary),
Layer.provide(Image.defaultLayer),
Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })),
Layer.provide(fragmentFailureLLM),
Layer.provideMerge(deps),
)
const fragmentFailureEnv = LayerNode.buildLayer(root, {
replacements: [...replacements, LayerNode.replace(LLM.node, fragmentFailureLLM)],
})
const itFragmentFailure = testEffect(fragmentFailureEnv)
const boot = Effect.fn("test.boot")(function* () {
+348 -17
View File
@@ -9958,6 +9958,74 @@
]
}
},
"/api/location": {
"get": {
"tags": ["opencode HttpApi"],
"operationId": "v2.location.get",
"parameters": [
{
"name": "location",
"in": "query",
"schema": {
"type": "object",
"properties": {
"directory": {
"type": "string"
},
"workspace": {
"type": "string"
}
},
"additionalProperties": false
},
"required": false,
"style": "deepObject",
"explode": true
}
],
"security": [],
"responses": {
"200": {
"description": "Location.Info",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/LocationInfo"
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestError"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedError"
}
}
}
}
},
"description": "Resolve the requested location or the server default location.",
"summary": "Get location",
"x-codeSamples": [
{
"lang": "js",
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.v2.location.get({\n ...\n})"
}
]
}
},
"/api/agent": {
"get": {
"tags": ["opencode HttpApi"],
@@ -10163,6 +10231,180 @@
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.v2.session.list({\n ...\n})"
}
]
},
"post": {
"tags": ["sessions"],
"operationId": "v2.session.create",
"parameters": [],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"$ref": "#/components/schemas/SessionV2Info"
}
},
"required": ["data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestError"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedError"
}
}
}
}
},
"description": "Create a session at the requested location.",
"summary": "Create session",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"id": {
"type": "string",
"pattern": "^ses"
},
"agent": {
"type": "string"
},
"model": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"providerID": {
"type": "string"
},
"variant": {
"type": "string"
}
},
"required": ["id", "providerID"],
"additionalProperties": false
},
"location": {
"$ref": "#/components/schemas/LocationRef"
}
},
"additionalProperties": false
}
}
},
"required": true
},
"x-codeSamples": [
{
"lang": "js",
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.v2.session.create({\n ...\n})"
}
]
}
},
"/api/session/{sessionID}": {
"get": {
"tags": ["sessions"],
"operationId": "v2.session.get",
"parameters": [
{
"name": "sessionID",
"in": "path",
"schema": {
"type": "string",
"pattern": "^ses"
},
"required": true
}
],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"$ref": "#/components/schemas/SessionV2Info"
}
},
"required": ["data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestError"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedError"
}
}
}
},
"404": {
"description": "SessionNotFoundError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/SessionNotFoundError"
},
{
"$ref": "#/components/schemas/SessionNotFoundError"
}
]
}
}
}
}
},
"description": "Retrieve a session by ID.",
"summary": "Get session",
"x-codeSamples": [
{
"lang": "js",
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.v2.session.get({\n ...\n})"
}
]
}
},
"/api/session/{sessionID}/prompt": {
@@ -11917,6 +12159,91 @@
]
}
},
"/api/session/{sessionID}/question": {
"get": {
"tags": ["session questions"],
"operationId": "v2.session.question.list",
"parameters": [
{
"name": "sessionID",
"in": "path",
"schema": {
"type": "string",
"pattern": "^ses"
},
"required": true
}
],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/QuestionV2Request"
}
}
},
"required": ["data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestError"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedError"
}
}
}
},
"404": {
"description": "SessionNotFoundError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/SessionNotFoundError"
},
{
"$ref": "#/components/schemas/SessionNotFoundError"
}
]
}
}
}
}
},
"description": "Retrieve pending question requests owned by a session.",
"summary": "List session question requests",
"x-codeSamples": [
{
"lang": "js",
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.v2.session.question.list({\n ...\n})"
}
]
}
},
"/api/session/{sessionID}/question/{requestID}/reply": {
"post": {
"tags": ["session questions"],
@@ -20623,23 +20950,6 @@
"required": ["_tag", "message"],
"additionalProperties": false
},
"ConflictError": {
"type": "object",
"properties": {
"_tag": {
"type": "string",
"enum": ["ConflictError"]
},
"message": {
"type": "string"
},
"resource": {
"type": "string"
}
},
"required": ["_tag", "message"],
"additionalProperties": false
},
"SessionNotFoundError": {
"type": "object",
"properties": {
@@ -20657,6 +20967,23 @@
"required": ["_tag", "sessionID", "message"],
"additionalProperties": false
},
"ConflictError": {
"type": "object",
"properties": {
"_tag": {
"type": "string",
"enum": ["ConflictError"]
},
"message": {
"type": "string"
},
"resource": {
"type": "string"
}
},
"required": ["_tag", "message"],
"additionalProperties": false
},
"ServiceUnavailableError": {
"type": "object",
"properties": {
@@ -28875,6 +29202,10 @@
"name": "opencode HttpApi",
"description": "Experimental HttpApi surface for selected instance routes."
},
{
"name": "opencode HttpApi",
"description": "Experimental HttpApi surface for selected instance routes."
},
{
"name": "sessions",
"description": "Experimental session routes."
+7 -8
View File
@@ -117,14 +117,13 @@ export const SessionGroup = HttpApiGroup.make("server.session")
location: Location.Ref.pipe(Schema.optional),
}),
success: Schema.Struct({ data: SessionV2.Info }),
})
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.create",
summary: "Create session",
description: "Create a session at the requested location.",
}),
),
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.create",
summary: "Create session",
description: "Create a session at the requested location.",
}),
),
)
.add(
HttpApiEndpoint.get("session.get", "/api/session/:sessionID", {
@@ -273,6 +273,16 @@ export function Autocomplete(props: {
}
}
const references = createMemo(() => data.location.reference.list() ?? [])
const referenceMatch = createMemo(() => {
if (!store.visible || store.visible === "/") return
const { baseQuery } = extractLineRange(search())
const slash = baseQuery.indexOf("/")
const alias = slash === -1 ? baseQuery : baseQuery.slice(0, slash)
return references().find((item) => !item.hidden && item.name === alias)
})
function normalizeMentionPath(filePath: string) {
const baseDir = sync.path.directory || paths.cwd
const absolute = path.resolve(filePath)
@@ -303,6 +313,7 @@ export function Autocomplete(props: {
() => search(),
async (query) => {
if (!store.visible || store.visible === "/") return []
if (referenceMatch()) return []
const { lineRange, baseQuery } = extractLineRange(query ?? "")
// Get files from SDK
@@ -399,6 +410,30 @@ export function Autocomplete(props: {
)
})
const referenceAliases = createMemo(() =>
references()
.filter((reference) => !reference.hidden)
.map(
(reference): AutocompleteOption => ({
display: "@" + reference.name,
description: ` ${reference.source.type === "git" ? reference.source.repository : reference.source.path}`,
onSelect: () => {
insertPart(reference.name, {
type: "file",
mime: "application/x-directory",
filename: reference.name,
url: pathToFileURL(reference.path).href,
source: {
type: "file",
text: { start: 0, end: 0, value: "" },
path: reference.name,
},
})
},
}),
),
)
const commands = createMemo((): AutocompleteOption[] => {
const results: AutocompleteOption[] = [...slashes()]
@@ -430,15 +465,21 @@ export function Autocomplete(props: {
const options = createMemo((prev: AutocompleteOption[] | undefined) => {
const filesValue = files()
const referenceMatchValue = referenceMatch()
const agentsValue = agents()
const referenceAliasesValue = referenceAliases()
const commandsValue = commands()
const searchValue = search()
if (store.visible === "@" && referenceMatchValue) {
return referenceAliasesValue.filter((item) => item.display === `@${referenceMatchValue.name}`)
}
// Files come from fff already fuzzy ranked and filtered
// it shouldn't be additionally sorted by fuzzysort as it will loose the results
const fileOptions: AutocompleteOption[] = store.visible === "@" ? filesValue || [] : []
const nonFileOptions: AutocompleteOption[] =
store.visible === "@" ? [...agentsValue, ...mcpResources()] : [...commandsValue]
store.visible === "@" ? [...referenceAliasesValue, ...agentsValue, ...mcpResources()] : [...commandsValue]
if (!searchValue) {
return [...nonFileOptions, ...fileOptions]
@@ -520,7 +561,7 @@ export function Autocomplete(props: {
const endCursor = input.logicalCursor
input.deleteRange(startCursor.row, startCursor.col, endCursor.row, endCursor.col)
input.insertText("@" + path)
input.insertText("@" + path + "/")
setStore("selected", 0)
}
+16
View File
@@ -9,6 +9,7 @@ import type {
PermissionV2Request,
ProviderV2Info,
QuestionV2Request,
ReferenceInfo,
SessionMessage,
SessionMessageAssistant,
SessionMessageAssistantReasoning,
@@ -27,6 +28,7 @@ type LocationData = {
command?: CommandV2Info[]
model?: ModelV2Info[]
provider?: ProviderV2Info[]
reference?: ReferenceInfo[]
skill?: SkillV2Info[]
}
@@ -416,6 +418,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
})
})
break
case "reference.updated":
void result.location.reference.refresh()
break
}
})
@@ -518,6 +523,16 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
setStore("location", key, "provider", result.data.data)
},
},
reference: {
list(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.reference
},
async refresh(ref?: LocationRef) {
const result = await sdk.client.v2.reference.list({ location: locationQuery(ref) }, { throwOnError: true })
const key = locationKey(result.data.location)
setStore("location", key, "reference", result.data.data)
},
},
skill: {
list(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.skill
@@ -537,6 +552,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
result.location.agent.refresh(),
result.location.model.refresh(),
result.location.provider.refresh(),
result.location.reference.refresh(),
result.location.command.refresh(),
result.location.skill.refresh(),
])
+50 -4
View File
@@ -94,7 +94,53 @@ test("refreshes resources into reactive getters", async () => {
}
})
test("sync v2 settles pending tools when a live failure arrives", async () => {
test("refreshes references after updates", async () => {
const events = createEventSource()
let requests = 0
const calls = createFetch((url) => {
if (url.pathname !== "/api/reference") return
requests++
return json({
location: { directory, project: { id: "proj_test", directory } },
data: requests === 1 ? [] : [{ name: "docs", path: "/docs", source: { type: "local", path: "/docs" } }],
})
})
let data!: ReturnType<typeof useData>
let ready!: () => void
const mounted = new Promise<void>((resolve) => {
ready = resolve
})
function Probe() {
data = useData()
onMount(ready)
return <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</SDKProvider>
</TestTuiContexts>
))
try {
await mounted
await wait(() => requests === 1)
emitEvent(events, { id: "evt_reference_1", type: "reference.updated", properties: {} })
await wait(() => data.location.reference.list()?.length === 1)
expect(data.location.reference.list()?.[0]?.name).toBe("docs")
} finally {
app.renderer.destroy()
}
})
test("settles pending tools when a live failure arrives", async () => {
const events = createEventSource()
const calls = createFetch()
let sync!: ReturnType<typeof useData>
@@ -223,7 +269,7 @@ test("sync v2 settles pending tools when a live failure arrives", async () => {
}
})
test("sync v2 renders admitted prompts only after promotion", async () => {
test("renders admitted prompts only after promotion", async () => {
const events = createEventSource()
const calls = createFetch()
let sync!: ReturnType<typeof useData>
@@ -287,7 +333,7 @@ test("sync v2 renders admitted prompts only after promotion", async () => {
}
})
test("sync v2 renders a promoted prompt when admission was missed", async () => {
test("renders a promoted prompt when admission was missed", async () => {
const events = createEventSource()
const calls = createFetch()
let sync!: ReturnType<typeof useData>
@@ -335,7 +381,7 @@ test("sync v2 renders a promoted prompt when admission was missed", async () =>
}
})
test("sync v2 projects live context updates with their message ID", async () => {
test("projects live context updates with their message ID", async () => {
const events = createEventSource()
const calls = createFetch()
let sync!: ReturnType<typeof useData>