diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index d68873ec7e..c8f40d0de1 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -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* () { diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 1dffe1ef5d..b4d425e24e 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -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." diff --git a/packages/server/src/groups/session.ts b/packages/server/src/groups/session.ts index 604fcf9073..a208de82f0 100644 --- a/packages/server/src/groups/session.ts +++ b/packages/server/src/groups/session.ts @@ -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", { diff --git a/packages/tui/src/component/prompt/autocomplete.tsx b/packages/tui/src/component/prompt/autocomplete.tsx index 781d8c8acb..a8a6b12883 100644 --- a/packages/tui/src/component/prompt/autocomplete.tsx +++ b/packages/tui/src/component/prompt/autocomplete.tsx @@ -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) } diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 62d7492a93..23df05c5f7 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -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(), ]) diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index cf1abf3667..dce3131576 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -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 + let ready!: () => void + const mounted = new Promise((resolve) => { + ready = resolve + }) + + function Probe() { + data = useData() + onMount(ready) + return + } + + const app = await testRender(() => ( + + + + + + + + + + )) + + 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 @@ -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 @@ -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 @@ -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