| author | |
| committer | |
| log | 0b83045d00d88d0a0b0866fe4942a50a41a08471 |
| tree | 1a0e76a95820fcdd52d7d397d23bc90eb4d00526 |
| parent | e8ca3a82806dff84438d5aba66b693468f7ca426 |
| signature | Signed by PGP key B5690EEEBB952194 |
19 files changed, 1023 insertions(+), 103 deletions(-)
apps/mobile/src/features/threads/use-composer-command-menu.ts+14-2| ... | @@ -29,8 +29,10 @@ import { | ... | @@ -29,8 +29,10 @@ import { |
| 29 | import { | 29 | import { |
| 30 | dedupeProviderSkillsByName, | 30 | dedupeProviderSkillsByName, |
| 31 | getProviderSkillsForSlashMenu, | 31 | getProviderSkillsForSlashMenu, |
| 32 | getProviderSlashCommandsForSlashMenu, | ||
| 32 | isProviderSkillUserInvocable, | 33 | isProviderSkillUserInvocable, |
| 33 | resolveProviderSkillsForCwd, | 34 | resolveProviderSkillsForCwd, |
| 35 | resolveProviderSlashCommandsForCwd, | ||
| 34 | } from "@t3tools/client-runtime/providerSkills"; | 36 | } from "@t3tools/client-runtime/providerSkills"; |
| 35 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | 37 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 36 | 38 | ||
| ... | @@ -329,6 +331,7 @@ export function useComposerCommandMenu({ | ... | @@ -329,6 +331,7 @@ export function useComposerCommandMenu({ |
| 329 | 331 | ||
| 330 | if (trigger.kind === "slash-command") { | 332 | if (trigger.kind === "slash-command") { |
| 331 | const q = trigger.query.toLowerCase(); | 333 | const q = trigger.query.toLowerCase(); |
| 334 | const visibleSkills = getProviderSkillsForSlashMenu(skills, true); | ||
| 332 | const commandItems = buildComposerSlashCommandItems({ | 335 | const commandItems = buildComposerSlashCommandItems({ |
| 333 | query: q, | 336 | query: q, |
| 334 | atMessageStart: trigger.rangeStart === 0, | 337 | atMessageStart: trigger.rangeStart === 0, |
| ... | @@ -336,10 +339,18 @@ export function useComposerCommandMenu({ | ... | @@ -336,10 +339,18 @@ export function useComposerCommandMenu({ |
| 336 | hasCompactableConversation, | 339 | hasCompactableConversation, |
| 337 | offersUsageLimits, | 340 | offersUsageLimits, |
| 338 | allowInteractionMode: onUpdateInteractionMode !== undefined, | 341 | allowInteractionMode: onUpdateInteractionMode !== undefined, |
| 339 | selectedProviderStatus, | 342 | selectedProviderStatus: selectedProviderStatus |
| 343 | ? { | ||
| 344 | ...selectedProviderStatus, | ||
| 345 | slashCommands: getProviderSlashCommandsForSlashMenu( | ||
| 346 | resolveProviderSlashCommandsForCwd(selectedProviderStatus, projectCwd), | ||
| 347 | visibleSkills, | ||
| 348 | ), | ||
| 349 | } | ||
| 350 | : null, | ||
| 340 | }); | 351 | }); |
| 341 | 352 | ||
| 342 | const skillItems = getProviderSkillsForSlashMenu(skills, true) | 353 | const skillItems = visibleSkills |
| 343 | .filter((skill) => matchesSlashSkillQuery(skill, q)) | 354 | .filter((skill) => matchesSlashSkillQuery(skill, q)) |
| 344 | .map((skill) => ({ | 355 | .map((skill) => ({ |
| 345 | id: `skill:${skill.name}`, | 356 | id: `skill:${skill.name}`, |
| ... | @@ -456,6 +467,7 @@ export function useComposerCommandMenu({ | ... | @@ -456,6 +467,7 @@ export function useComposerCommandMenu({ |
| 456 | onUpdateInteractionMode, | 467 | onUpdateInteractionMode, |
| 457 | pathSearch.entries, | 468 | pathSearch.entries, |
| 458 | pullRequestSearch.entries, | 469 | pullRequestSearch.entries, |
| 470 | projectCwd, | ||
| 459 | selectedProviderStatus, | 471 | selectedProviderStatus, |
| 460 | skills, | 472 | skills, |
| 461 | trigger, | 473 | trigger, |
apps/server/scripts/acp-mock-agent.ts+10-8| ... | @@ -784,7 +784,7 @@ const program = Effect.gen(function* () { | ... | @@ -784,7 +784,7 @@ const program = Effect.gen(function* () { |
| 784 | sessionId: requestedSessionId, | 784 | sessionId: requestedSessionId, |
| 785 | update: { | 785 | update: { |
| 786 | sessionUpdate: "agent_message_chunk", | 786 | sessionUpdate: "agent_message_chunk", |
| 787 | content: { type: "text", text: "hello from " }, | 787 | content: { type: "text", text: "hello from" }, |
| 788 | }, | 788 | }, |
| 789 | }); | 789 | }); |
| 790 | 790 | ||
| ... | @@ -826,13 +826,15 @@ const program = Effect.gen(function* () { | ... | @@ -826,13 +826,15 @@ const program = Effect.gen(function* () { |
| 826 | }); | 826 | }); |
| 827 | } | 827 | } |
| 828 | 828 | ||
| 829 | writeJsonRpcNotification("session/update", { | 829 | for (const text of [" ", "mo", "ck"]) { |
| 830 | sessionId: requestedSessionId, | 830 | writeJsonRpcNotification("session/update", { |
| 831 | update: { | 831 | sessionId: requestedSessionId, |
| 832 | sessionUpdate: "agent_message_chunk", | 832 | update: { |
| 833 | content: { type: "text", text: "mock" }, | 833 | sessionUpdate: "agent_message_chunk", |
| 834 | }, | 834 | content: { type: "text", text }, |
| 835 | }); | 835 | }, |
| 836 | }); | ||
| 837 | } | ||
| 836 | 838 | ||
| 837 | return yield* Effect.never; | 839 | return yield* Effect.never; |
| 838 | } | 840 | } |
apps/server/src/provider/Drivers/CursorDriver.ts+32-22| ... | @@ -31,6 +31,7 @@ import { | ... | @@ -31,6 +31,7 @@ import { |
| 31 | checkCursorProviderStatus, | 31 | checkCursorProviderStatus, |
| 32 | makeCursorModelDiscovery, | 32 | makeCursorModelDiscovery, |
| 33 | enrichCursorSnapshot, | 33 | enrichCursorSnapshot, |
| 34 | makeCursorCommandCatalog, | ||
| 34 | } from "../Layers/CursorProvider.ts"; | 35 | } from "../Layers/CursorProvider.ts"; |
| 35 | import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; | 36 | import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; |
| 36 | import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; | 37 | import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; |
| ... | @@ -53,7 +54,7 @@ import { | ... | @@ -53,7 +54,7 @@ import { |
| 53 | makeProviderSnapshotSettingsSource, | 54 | makeProviderSnapshotSettingsSource, |
| 54 | type ProviderSnapshotSettings, | 55 | type ProviderSnapshotSettings, |
| 55 | } from "../providerUpdateSettings.ts"; | 56 | } from "../providerUpdateSettings.ts"; |
| 56 | import { probeCursorSkills } from "./CursorSkills.ts"; | 57 | import { discoverCursorSkills, probeCursorSkills } from "./CursorSkills.ts"; |
| 57 | const decodeCursorSettings = Schema.decodeSync(CursorSettings); | 58 | const decodeCursorSettings = Schema.decodeSync(CursorSettings); |
| 58 | 59 | ||
| 59 | const DRIVER_KIND = ProviderDriverKind.make("cursor"); | 60 | const DRIVER_KIND = ProviderDriverKind.make("cursor"); |
| ... | @@ -130,11 +131,6 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = { | ... | @@ -130,11 +131,6 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = { |
| 130 | ), | 131 | ), |
| 131 | ); | 132 | ); |
| 132 | 133 | ||
| 133 | const adapter = yield* makeCursorAdapter(effectiveConfig, { | ||
| 134 | environment: processEnv, | ||
| 135 | ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), | ||
| 136 | instanceId, | ||
| 137 | }); | ||
| 138 | const textGeneration = yield* makeCursorTextGeneration(effectiveConfig, processEnv); | 134 | const textGeneration = yield* makeCursorTextGeneration(effectiveConfig, processEnv); |
| 139 | 135 | ||
| 140 | const discoverModels = yield* makeCursorModelDiscovery(effectiveConfig, processEnv); | 136 | const discoverModels = yield* makeCursorModelDiscovery(effectiveConfig, processEnv); |
| ... | @@ -151,7 +147,9 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = { | ... | @@ -151,7 +147,9 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = { |
| 151 | ); | 147 | ); |
| 152 | 148 | ||
| 153 | const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); | 149 | const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); |
| 154 | const snapshot = yield* makeManagedServerProvider<ProviderSnapshotSettings<CursorSettings>>({ | 150 | const managedSnapshot = yield* makeManagedServerProvider< |
| 151 | ProviderSnapshotSettings<CursorSettings> | ||
| 152 | >({ | ||
| 155 | resolveMaintenance, | 153 | resolveMaintenance, |
| 156 | getSettings: snapshotSettings.getSettings, | 154 | getSettings: snapshotSettings.getSettings, |
| 157 | streamSettings: snapshotSettings.streamSettings, | 155 | streamSettings: snapshotSettings.streamSettings, |
| ... | @@ -188,6 +186,20 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = { | ... | @@ -188,6 +186,20 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = { |
| 188 | ), | 186 | ), |
| 189 | ); | 187 | ); |
| 190 | 188 | ||
| 189 | const { snapshot, onAvailableCommands, snapshotForCwd } = | ||
| 190 | yield* makeCursorCommandCatalog(managedSnapshot); | ||
| 191 | const adapter = yield* makeCursorAdapter(effectiveConfig, { | ||
| 192 | environment: processEnv, | ||
| 193 | ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), | ||
| 194 | instanceId, | ||
| 195 | onAvailableCommands: (commands, cwd) => | ||
| 196 | discoverCursorSkills(cwd, processEnv).pipe( | ||
| 197 | Effect.provideService(FileSystem.FileSystem, fileSystem), | ||
| 198 | Effect.provideService(Path.Path, path), | ||
| 199 | Effect.flatMap((skills) => onAvailableCommands(commands, cwd, skills)), | ||
| 200 | ), | ||
| 201 | }); | ||
| 202 | |||
| 191 | return { | 203 | return { |
| 192 | instanceId, | 204 | instanceId, |
| 193 | driverKind: DRIVER_KIND, | 205 | driverKind: DRIVER_KIND, |
| ... | @@ -199,22 +211,20 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = { | ... | @@ -199,22 +211,20 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = { |
| 199 | snapshotForCwd: (cwd) => | 211 | snapshotForCwd: (cwd) => |
| 200 | !effectiveConfig.enabled | 212 | !effectiveConfig.enabled |
| 201 | ? snapshot.getSnapshot | 213 | ? snapshot.getSnapshot |
| 202 | : Effect.all([ | 214 | : probeCursorSkills(cwd, processEnv).pipe( |
| 203 | snapshot.getSnapshot, | 215 | Effect.provideService(FileSystem.FileSystem, fileSystem), |
| 204 | probeCursorSkills(cwd, processEnv).pipe( | 216 | Effect.provideService(Path.Path, path), |
| 205 | Effect.provideService(FileSystem.FileSystem, fileSystem), | 217 | Effect.mapError( |
| 206 | Effect.provideService(Path.Path, path), | 218 | (cause) => |
| 207 | Effect.mapError( | 219 | new ProviderDriverError({ |
| 208 | (cause) => | 220 | driver: DRIVER_KIND, |
| 209 | new ProviderDriverError({ | 221 | instanceId, |
| 210 | driver: DRIVER_KIND, | 222 | detail: `Failed to discover Cursor skills for '${cwd}'`, |
| 211 | instanceId, | 223 | cause, |
| 212 | detail: `Failed to discover Cursor skills for '${cwd}'`, | 224 | }), |
| 213 | cause, | ||
| 214 | }), | ||
| 215 | ), | ||
| 216 | ), | 225 | ), |
| 217 | ]).pipe(Effect.map(([machineSnapshot, skills]) => ({ ...machineSnapshot, skills }))), | 226 | Effect.flatMap((skills) => snapshotForCwd(cwd, skills)), |
| 227 | ), | ||
| 218 | adapter, | 228 | adapter, |
| 219 | textGeneration, | 229 | textGeneration, |
| 220 | } satisfies ProviderInstance; | 230 | } satisfies ProviderInstance; |
apps/server/src/provider/Drivers/OpenCodeDriver.ts+20-7| ... | @@ -31,10 +31,11 @@ import { | ... | @@ -31,10 +31,11 @@ import { |
| 31 | checkOpenCodeProviderStatus, | 31 | checkOpenCodeProviderStatus, |
| 32 | makePendingOpenCodeProvider, | 32 | makePendingOpenCodeProvider, |
| 33 | openCodeSkillsToServerProviderSkills, | 33 | openCodeSkillsToServerProviderSkills, |
| 34 | openCodeCommandsToServerProviderSlashCommands, | ||
| 34 | } from "../Layers/OpenCodeProvider.ts"; | 35 | } from "../Layers/OpenCodeProvider.ts"; |
| 35 | import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; | 36 | import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; |
| 36 | import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; | 37 | import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; |
| 37 | import { OpenCodeRuntime } from "../opencodeRuntime.ts"; | 38 | import { OpenCodeRuntime, loadOpenCodeCommands } from "../opencodeRuntime.ts"; |
| 38 | import * as OpenCodeServerOwner from "../OpenCodeServerOwner.ts"; | 39 | import * as OpenCodeServerOwner from "../OpenCodeServerOwner.ts"; |
| 39 | import { | 40 | import { |
| 40 | defaultProviderContinuationIdentity, | 41 | defaultProviderContinuationIdentity, |
| ... | @@ -164,7 +165,18 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv> | ... | @@ -164,7 +165,18 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv> |
| 164 | // empty skill list and poisons the workspace snapshot the `$` picker | 165 | // empty skill list and poisons the workspace snapshot the `$` picker |
| 165 | // reads. The SDK `app.skills` endpoint honors the per-request directory | 166 | // reads. The SDK `app.skills` endpoint honors the per-request directory |
| 166 | // and returns complete results regardless of size. | 167 | // and returns complete results regardless of size. |
| 167 | const loadSkillsForCwd = (cwd: string) => | 168 | const loadWorkspaceInventory = (client: Parameters<typeof loadOpenCodeCommands>[0]) => |
| 169 | Effect.all( | ||
| 170 | { | ||
| 171 | skills: openCodeRuntime.loadOpenCodeSkills(client), | ||
| 172 | commands: loadOpenCodeCommands(client).pipe( | ||
| 173 | Effect.timeout("10 seconds"), | ||
| 174 | Effect.orElseSucceed(() => []), | ||
| 175 | ), | ||
| 176 | }, | ||
| 177 | { concurrency: "unbounded" }, | ||
| 178 | ); | ||
| 179 | const loadWorkspaceForCwd = (cwd: string) => | ||
| 168 | effectiveConfig.serverUrl.trim().length > 0 | 180 | effectiveConfig.serverUrl.trim().length > 0 |
| 169 | ? Effect.scoped( | 181 | ? Effect.scoped( |
| 170 | Effect.gen(function* () { | 182 | Effect.gen(function* () { |
| ... | @@ -184,11 +196,11 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv> | ... | @@ -184,11 +196,11 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv> |
| 184 | ? { serverPassword: effectiveConfig.serverPassword } | 196 | ? { serverPassword: effectiveConfig.serverPassword } |
| 185 | : {}), | 197 | : {}), |
| 186 | }); | 198 | }); |
| 187 | return yield* openCodeRuntime.loadOpenCodeSkills(client); | 199 | return yield* loadWorkspaceInventory(client); |
| 188 | }), | 200 | }), |
| 189 | ) | 201 | ) |
| 190 | : serverOwner.withServer((server) => | 202 | : serverOwner.withServer((server) => |
| 191 | openCodeRuntime.loadOpenCodeSkills( | 203 | loadWorkspaceInventory( |
| 192 | openCodeRuntime.createOpenCodeSdkClient({ | 204 | openCodeRuntime.createOpenCodeSdkClient({ |
| 193 | baseUrl: server.url, | 205 | baseUrl: server.url, |
| 194 | directory: cwd, | 206 | directory: cwd, |
| ... | @@ -247,18 +259,19 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv> | ... | @@ -247,18 +259,19 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv> |
| 247 | ? snapshot.getSnapshot | 259 | ? snapshot.getSnapshot |
| 248 | : Effect.all([ | 260 | : Effect.all([ |
| 249 | snapshot.getSnapshot, | 261 | snapshot.getSnapshot, |
| 250 | loadSkillsForCwd(cwd).pipe(Effect.timeout("20 seconds")), | 262 | loadWorkspaceForCwd(cwd).pipe(Effect.timeout("20 seconds")), |
| 251 | ]).pipe( | 263 | ]).pipe( |
| 252 | Effect.map(([machineSnapshot, skills]) => ({ | 264 | Effect.map(([machineSnapshot, { skills, commands }]) => ({ |
| 253 | ...machineSnapshot, | 265 | ...machineSnapshot, |
| 254 | skills: openCodeSkillsToServerProviderSkills(skills), | 266 | skills: openCodeSkillsToServerProviderSkills(skills), |
| 267 | slashCommands: openCodeCommandsToServerProviderSlashCommands(commands), | ||
| 255 | })), | 268 | })), |
| 256 | Effect.mapError( | 269 | Effect.mapError( |
| 257 | (cause) => | 270 | (cause) => |
| 258 | new ProviderDriverError({ | 271 | new ProviderDriverError({ |
| 259 | driver: DRIVER_KIND, | 272 | driver: DRIVER_KIND, |
| 260 | instanceId, | 273 | instanceId, |
| 261 | detail: `Failed to probe OpenCode skills for '${cwd}'`, | 274 | detail: `Failed to probe OpenCode commands and skills for '${cwd}'`, |
| 262 | cause, | 275 | cause, |
| 263 | }), | 276 | }), |
| 264 | ), | 277 | ), |
apps/server/src/provider/Layers/CursorAdapter.test.ts+3-1| ... | @@ -302,7 +302,7 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { | ... | @@ -302,7 +302,7 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { |
| 302 | }), | 302 | }), |
| 303 | ); | 303 | ); |
| 304 | 304 | ||
| 305 | it.effect("sends selected project skills in Cursor's native slash form", () => | 305 | it.effect("sends skills in Cursor's native form and preserves exact slash command input", () => |
| 306 | Effect.gen(function* () { | 306 | Effect.gen(function* () { |
| 307 | const adapter = yield* CursorAdapter; | 307 | const adapter = yield* CursorAdapter; |
| 308 | const settings = yield* ServerSettingsService; | 308 | const settings = yield* ServerSettingsService; |
| ... | @@ -347,6 +347,7 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { | ... | @@ -347,6 +347,7 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { |
| 347 | ], | 347 | ], |
| 348 | ], | 348 | ], |
| 349 | ); | 349 | ); |
| 350 | yield* adapter.sendTurn({ threadId, input: "/copy-request-id" }); | ||
| 350 | yield* adapter.stopSession(threadId); | 351 | yield* adapter.stopSession(threadId); |
| 351 | 352 | ||
| 352 | const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); | 353 | const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); |
| ... | @@ -360,6 +361,7 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { | ... | @@ -360,6 +361,7 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { |
| 360 | { type: "text", text: "please /review this" }, | 361 | { type: "text", text: "please /review this" }, |
| 361 | { type: "text", text: buildRuntimeInstructions({ harness: "Cursor" }) }, | 362 | { type: "text", text: buildRuntimeInstructions({ harness: "Cursor" }) }, |
| 362 | ], | 363 | ], |
| 364 | [{ type: "text", text: "/copy-request-id" }], | ||
| 363 | ], | 365 | ], |
| 364 | ); | 366 | ); |
| 365 | }), | 367 | }), |
apps/server/src/provider/Layers/CursorAdapter.ts+20-8| ... | @@ -118,6 +118,10 @@ export interface CursorAdapterLiveOptions { | ... | @@ -118,6 +118,10 @@ export interface CursorAdapterLiveOptions { |
| 118 | * the latest snapshot so the closure isn't stale. | 118 | * the latest snapshot so the closure isn't stale. |
| 119 | */ | 119 | */ |
| 120 | readonly resolveSettings?: Effect.Effect<CursorSettings>; | 120 | readonly resolveSettings?: Effect.Effect<CursorSettings>; |
| 121 | readonly onAvailableCommands?: ( | ||
| 122 | commands: ReadonlyArray<EffectAcpSchema.AvailableCommand>, | ||
| 123 | cwd: string, | ||
| 124 | ) => Effect.Effect<void>; | ||
| 121 | } | 125 | } |
| 122 | 126 | ||
| 123 | interface PendingApproval { | 127 | interface PendingApproval { |
| ... | @@ -810,6 +814,11 @@ export function makeCursorAdapter( | ... | @@ -810,6 +814,11 @@ export function makeCursorAdapter( |
| 810 | return; | 814 | return; |
| 811 | case "ModeChanged": | 815 | case "ModeChanged": |
| 812 | return; | 816 | return; |
| 817 | case "AvailableCommandsUpdated": | ||
| 818 | yield* ( | ||
| 819 | options?.onAvailableCommands?.(event.availableCommands, cwd) ?? Effect.void | ||
| 820 | ); | ||
| 821 | return; | ||
| 813 | case "AssistantItemStarted": | 822 | case "AssistantItemStarted": |
| 814 | ctx.assistantReply = new CursorTransportFailure(); | 823 | ctx.assistantReply = new CursorTransportFailure(); |
| 815 | yield* offerRuntimeEvent( | 824 | yield* offerRuntimeEvent( |
| ... | @@ -1057,16 +1066,19 @@ export function makeCursorAdapter( | ... | @@ -1057,16 +1066,19 @@ export function makeCursorAdapter( |
| 1057 | }); | 1066 | }); |
| 1058 | } | 1067 | } |
| 1059 | 1068 | ||
| 1060 | // ACP has no system-message field; keep runtime context separate from the user's text. | 1069 | // ACP commands parse the complete text. Extra context can turn an exact |
| 1070 | // command into an ordinary model prompt or change its arguments. | ||
| 1061 | const result = yield* ctx.acp | 1071 | const result = yield* ctx.acp |
| 1062 | .prompt({ | 1072 | .prompt({ |
| 1063 | prompt: [ | 1073 | prompt: /^\/[^\s/]+(?:\s|$)/.test(rawPrompt) |
| 1064 | ...promptParts, | 1074 | ? promptParts |
| 1065 | { | 1075 | : [ |
| 1066 | type: "text", | 1076 | ...promptParts, |
| 1067 | text: buildRuntimeInstructions({ harness: "Cursor", model: resolvedModel }), | 1077 | { |
| 1068 | }, | 1078 | type: "text", |
| 1069 | ], | 1079 | text: buildRuntimeInstructions({ harness: "Cursor", model: resolvedModel }), |
| 1080 | }, | ||
| 1081 | ], | ||
| 1070 | }) | 1082 | }) |
| 1071 | .pipe( | 1083 | .pipe( |
| 1072 | Effect.mapError((error) => | 1084 | Effect.mapError((error) => |
apps/server/src/provider/Layers/CursorProvider.test.ts+85-1| ... | @@ -1,14 +1,16 @@ | ... | @@ -1,14 +1,16 @@ |
| 1 | import * as NodeOS from "node:os"; | 1 | import * as NodeOS from "node:os"; |
| 2 | 2 | ||
| 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 4 | import { it as effectIt } from "@effect/vitest"; | ||
| 4 | import type * as Crypto from "effect/Crypto"; | 5 | import type * as Crypto from "effect/Crypto"; |
| 5 | import * as Effect from "effect/Effect"; | 6 | import * as Effect from "effect/Effect"; |
| 6 | import * as FileSystem from "effect/FileSystem"; | 7 | import * as FileSystem from "effect/FileSystem"; |
| 7 | import * as Path from "effect/Path"; | 8 | import * as Path from "effect/Path"; |
| 9 | import * as Stream from "effect/Stream"; | ||
| 8 | import type * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; | 10 | import type * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; |
| 9 | import { describe, expect, it } from "vite-plus/test"; | 11 | import { describe, expect, it } from "vite-plus/test"; |
| 10 | import type * as EffectAcpSchema from "effect-acp/schema"; | 12 | import type * as EffectAcpSchema from "effect-acp/schema"; |
| 11 | import type { CursorSettings } from "@t3tools/contracts"; | 13 | import { ProviderDriverKind, ProviderInstanceId, type CursorSettings } from "@t3tools/contracts"; |
| 12 | import { createModelCapabilities } from "@t3tools/shared/model"; | 14 | import { createModelCapabilities } from "@t3tools/shared/model"; |
| 13 | 15 | ||
| 14 | import { | 16 | import { |
| ... | @@ -17,6 +19,7 @@ import { | ... | @@ -17,6 +19,7 @@ import { |
| 17 | checkCursorProviderStatus, | 19 | checkCursorProviderStatus, |
| 18 | discoverCursorModelsViaAcp, | 20 | discoverCursorModelsViaAcp, |
| 19 | makeCursorModelDiscovery, | 21 | makeCursorModelDiscovery, |
| 22 | makeCursorCommandCatalog, | ||
| 20 | getCursorParameterizedModelPickerUnsupportedMessage, | 23 | getCursorParameterizedModelPickerUnsupportedMessage, |
| 21 | parseCursorAboutOutput, | 24 | parseCursorAboutOutput, |
| 22 | parseCursorCliConfigChannel, | 25 | parseCursorCliConfigChannel, |
| ... | @@ -475,6 +478,87 @@ describe("Cursor skills", () => { | ... | @@ -475,6 +478,87 @@ describe("Cursor skills", () => { |
| 475 | }); | 478 | }); |
| 476 | }); | 479 | }); |
| 477 | 480 | ||
| 481 | describe("Cursor command catalog", () => { | ||
| 482 | effectIt.effect( | ||
| 483 | "publishes workspace commands without leaking them globally and retains them across refreshes", | ||
| 484 | () => | ||
| 485 | Effect.gen(function* () { | ||
| 486 | const base = { | ||
| 487 | ...buildCursorProviderSnapshot({ | ||
| 488 | checkedAt: "2026-01-01T00:00:00.000Z", | ||
| 489 | cursorSettings: baseCursorSettings, | ||
| 490 | parsed: { version: null, status: "ready", auth: { status: "authenticated" } }, | ||
| 491 | }), | ||
| 492 | instanceId: ProviderInstanceId.make("cursor-catalog"), | ||
| 493 | driver: ProviderDriverKind.make("cursor"), | ||
| 494 | }; | ||
| 495 | const catalog = yield* makeCursorCommandCatalog({ | ||
| 496 | getSnapshot: Effect.succeed(base), | ||
| 497 | refresh: Effect.succeed(base), | ||
| 498 | streamChanges: Stream.empty, | ||
| 499 | resolveMaintenance: () => Effect.die("Not used"), | ||
| 500 | applyUsageLimits: () => Effect.void, | ||
| 501 | }); | ||
| 502 | const skills = [ | ||
| 503 | { name: "review", path: "/one/.cursor/skills/review/SKILL.md", enabled: true }, | ||
| 504 | ]; | ||
| 505 | const probedSkills = [ | ||
| 506 | { name: "explain", path: "/probed/.cursor/skills/explain/SKILL.md", enabled: true }, | ||
| 507 | ]; | ||
| 508 | yield* catalog.snapshotForCwd("/probed", probedSkills); | ||
| 509 | yield* catalog.onAvailableCommands( | ||
| 510 | [ | ||
| 511 | { name: "review", description: "Review changes", input: { hint: "target" } }, | ||
| 512 | { name: "compact", description: "Native duplicate" }, | ||
| 513 | { name: "review", description: "Duplicate" }, | ||
| 514 | ], | ||
| 515 | "/one", | ||
| 516 | skills, | ||
| 517 | ); | ||
| 518 | yield* catalog.onAvailableCommands([{ name: "deploy", description: "Deploy" }], "/two", []); | ||
| 519 | const reprobed = yield* catalog.snapshotForCwd("/one", skills); | ||
| 520 | expect(reprobed.slashCommands.map((command) => command.name)).toEqual([ | ||
| 521 | "compact", | ||
| 522 | "review", | ||
| 523 | ]); | ||
| 524 | const published = yield* catalog.snapshot.streamChanges.pipe( | ||
| 525 | Stream.take(1), | ||
| 526 | Stream.runCollect, | ||
| 527 | ); | ||
| 528 | expect(published[0]?.slashCommands.map((command) => command.name)).toEqual(["compact"]); | ||
| 529 | const refreshed = yield* catalog.snapshot.refresh; | ||
| 530 | expect( | ||
| 531 | refreshed.workspaceSnapshots?.find((entry) => entry.cwd === "/probed"), | ||
| 532 | ).toMatchObject({ | ||
| 533 | slashCommands: [{ name: "compact" }], | ||
| 534 | skills: probedSkills, | ||
| 535 | }); | ||
| 536 | expect(refreshed.workspaceSnapshots?.find((entry) => entry.cwd === "/one")).toMatchObject({ | ||
| 537 | slashCommands: [ | ||
| 538 | { name: "compact" }, | ||
| 539 | { name: "review", description: "Review changes", input: { hint: "target" } }, | ||
| 540 | ], | ||
| 541 | skills, | ||
| 542 | }); | ||
| 543 | yield* catalog.onAvailableCommands([], "/one", skills); | ||
| 544 | const updated = yield* catalog.snapshot.getSnapshot; | ||
| 545 | expect( | ||
| 546 | updated.workspaceSnapshots?.find((entry) => entry.cwd === "/probed")?.skills, | ||
| 547 | ).toEqual(probedSkills); | ||
| 548 | expect( | ||
| 549 | updated.workspaceSnapshots | ||
| 550 | ?.find((entry) => entry.cwd === "/one") | ||
| 551 | ?.slashCommands.map((command) => command.name), | ||
| 552 | ).toEqual(["compact"]); | ||
| 553 | expect( | ||
| 554 | updated.workspaceSnapshots | ||
| 555 | ?.find((entry) => entry.cwd === "/two") | ||
| 556 | ?.slashCommands.map((command) => command.name), | ||
| 557 | ).toEqual(["compact", "deploy"]); | ||
| 558 | }), | ||
| 559 | ); | ||
| 560 | }); | ||
| 561 | |||
| 478 | describe("buildCursorProviderSnapshot", () => { | 562 | describe("buildCursorProviderSnapshot", () => { |
| 479 | it("downgrades ready status to warning when ACP model discovery times out", () => { | 563 | it("downgrades ready status to warning when ACP model discovery times out", () => { |
| 480 | expect( | 564 | expect( |
apps/server/src/provider/Layers/CursorProvider.ts+90| ... | @@ -22,6 +22,8 @@ import * as Option from "effect/Option"; | ... | @@ -22,6 +22,8 @@ import * as Option from "effect/Option"; |
| 22 | import * as Path from "effect/Path"; | 22 | import * as Path from "effect/Path"; |
| 23 | import * as Result from "effect/Result"; | 23 | import * as Result from "effect/Result"; |
| 24 | import * as Schema from "effect/Schema"; | 24 | import * as Schema from "effect/Schema"; |
| 25 | import * as Stream from "effect/Stream"; | ||
| 26 | import * as SubscriptionRef from "effect/SubscriptionRef"; | ||
| 25 | import { HttpClient } from "effect/unstable/http"; | 27 | import { HttpClient } from "effect/unstable/http"; |
| 26 | import * as ChildProcess from "effect/unstable/process/ChildProcess"; | 28 | import * as ChildProcess from "effect/unstable/process/ChildProcess"; |
| 27 | import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; | 29 | import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; |
| ... | @@ -49,6 +51,94 @@ import { | ... | @@ -49,6 +51,94 @@ import { |
| 49 | } from "../providerMaintenance.ts"; | 51 | } from "../providerMaintenance.ts"; |
| 50 | import * as AcpSessionRuntime from "../acp/AcpSessionRuntime.ts"; | 52 | import * as AcpSessionRuntime from "../acp/AcpSessionRuntime.ts"; |
| 51 | import { CursorListAvailableModelsResponse } from "../acp/CursorAcpExtension.ts"; | 53 | import { CursorListAvailableModelsResponse } from "../acp/CursorAcpExtension.ts"; |
| 54 | import type { ServerProviderShape } from "../Services/ServerProvider.ts"; | ||
| 55 | |||
| 56 | /** Session command catalogs stay scoped to their workspace across health refreshes. */ | ||
| 57 | export const makeCursorCommandCatalog = Effect.fn("makeCursorCommandCatalog")(function* ( | ||
| 58 | provider: ServerProviderShape, | ||
| 59 | ) { | ||
| 60 | const workspaces = yield* SubscriptionRef.make<NonNullable<ServerProvider["workspaceSnapshots"]>>( | ||
| 61 | [], | ||
| 62 | ); | ||
| 63 | const getSnapshot = Effect.all([provider.getSnapshot, SubscriptionRef.get(workspaces)]).pipe( | ||
| 64 | Effect.map(([snapshot, workspaceSnapshots]) => | ||
| 65 | workspaceSnapshots.length > 0 ? { ...snapshot, workspaceSnapshots } : snapshot, | ||
| 66 | ), | ||
| 67 | ); | ||
| 68 | const snapshotForCwd = Effect.fn("CursorCommandCatalog.snapshotForCwd")(function* ( | ||
| 69 | cwd: string, | ||
| 70 | skills: ServerProvider["skills"], | ||
| 71 | ) { | ||
| 72 | const machineSnapshot = yield* provider.getSnapshot; | ||
| 73 | const checkedAt = DateTime.formatIso(yield* DateTime.now); | ||
| 74 | yield* SubscriptionRef.update(workspaces, (entries) => | ||
| 75 | [ | ||
| 76 | ...entries.filter((entry) => entry.cwd !== cwd), | ||
| 77 | { | ||
| 78 | cwd, | ||
| 79 | checkedAt, | ||
| 80 | slashCommands: | ||
| 81 | entries.find((entry) => entry.cwd === cwd)?.slashCommands ?? | ||
| 82 | machineSnapshot.slashCommands, | ||
| 83 | skills, | ||
| 84 | }, | ||
| 85 | ].slice(-16), | ||
| 86 | ); | ||
| 87 | const snapshot = yield* getSnapshot; | ||
| 88 | return { | ||
| 89 | ...snapshot, | ||
| 90 | checkedAt, | ||
| 91 | slashCommands: | ||
| 92 | snapshot.workspaceSnapshots?.find((entry) => entry.cwd === cwd)?.slashCommands ?? | ||
| 93 | snapshot.slashCommands, | ||
| 94 | skills, | ||
| 95 | }; | ||
| 96 | }); | ||
| 97 | const onAvailableCommands = Effect.fn("CursorCommandCatalog.onAvailableCommands")(function* ( | ||
| 98 | commands: ReadonlyArray<EffectAcpSchema.AvailableCommand>, | ||
| 99 | cwd: string, | ||
| 100 | skills: ServerProvider["skills"], | ||
| 101 | ) { | ||
| 102 | const seen = new Set([COMPACT_SLASH_COMMAND.name]); | ||
| 103 | const slashCommands = [ | ||
| 104 | COMPACT_SLASH_COMMAND, | ||
| 105 | ...commands.flatMap((command) => { | ||
| 106 | const name = command.name.trim(); | ||
| 107 | if (!name || seen.has(name)) return []; | ||
| 108 | seen.add(name); | ||
| 109 | const description = command.description.trim(); | ||
| 110 | const hint = command.input?.hint.trim(); | ||
| 111 | return [ | ||
| 112 | { | ||
| 113 | name, | ||
| 114 | ...(description ? { description } : {}), | ||
| 115 | ...(hint ? { input: { hint } } : {}), | ||
| 116 | }, | ||
| 117 | ]; | ||
| 118 | }), | ||
| 119 | ]; | ||
| 120 | const checkedAt = DateTime.formatIso(yield* DateTime.now); | ||
| 121 | yield* SubscriptionRef.update(workspaces, (entries) => | ||
| 122 | [ | ||
| 123 | ...entries.filter((entry) => entry.cwd !== cwd), | ||
| 124 | { cwd, checkedAt, slashCommands, skills }, | ||
| 125 | ].slice(-16), | ||
| 126 | ); | ||
| 127 | }); | ||
| 128 | return { | ||
| 129 | onAvailableCommands, | ||
| 130 | snapshotForCwd, | ||
| 131 | snapshot: { | ||
| 132 | ...provider, | ||
| 133 | getSnapshot, | ||
| 134 | refresh: provider.refresh.pipe(Effect.andThen(getSnapshot)), | ||
| 135 | streamChanges: Stream.merge( | ||
| 136 | provider.streamChanges.pipe(Stream.map(() => undefined)), | ||
| 137 | SubscriptionRef.changes(workspaces).pipe(Stream.map(() => undefined)), | ||
| 138 | ).pipe(Stream.mapEffect(() => getSnapshot)), | ||
| 139 | } satisfies ServerProviderShape, | ||
| 140 | }; | ||
| 141 | }); | ||
| 52 | 142 | ||
| 53 | const decodeCursorListAvailableModelsResponse = Schema.decodeUnknownEffect( | 143 | const decodeCursorListAvailableModelsResponse = Schema.decodeUnknownEffect( |
| 54 | CursorListAvailableModelsResponse, | 144 | CursorListAvailableModelsResponse, |
apps/server/src/provider/Layers/GrokAdapter.test.ts+63-5| ... | @@ -292,7 +292,7 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { | ... | @@ -292,7 +292,7 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { |
| 292 | ); | 292 | ); |
| 293 | } | 293 | } |
| 294 | 294 | ||
| 295 | it.effect("sends runtime context with the current model without changing saved prompts", () => | 295 | it.effect("keeps runtime context out of native command arguments", () => |
| 296 | Effect.gen(function* () { | 296 | Effect.gen(function* () { |
| 297 | const threadId = ThreadId.make("grok-runtime-context"); | 297 | const threadId = ThreadId.make("grok-runtime-context"); |
| 298 | const tempDir = yield* Effect.promise(() => | 298 | const tempDir = yield* Effect.promise(() => |
| ... | @@ -337,6 +337,14 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { | ... | @@ -337,6 +337,14 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { |
| 337 | ], | 337 | ], |
| 338 | ], | 338 | ], |
| 339 | ); | 339 | ); |
| 340 | const permissionError = yield* adapter | ||
| 341 | .sendTurn({ threadId, input: "/always-approve on" }) | ||
| 342 | .pipe(Effect.flip); | ||
| 343 | if (permissionError._tag !== "ProviderAdapterRequestError") { | ||
| 344 | assert.fail(`Unexpected error: ${permissionError._tag}`); | ||
| 345 | } | ||
| 346 | assert.include(permissionError.detail, "permission selector"); | ||
| 347 | yield* adapter.sendTurn({ threadId, input: "/goal status" }); | ||
| 340 | yield* adapter.stopSession(threadId); | 348 | yield* adapter.stopSession(threadId); |
| 341 | const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); | 349 | const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); |
| 342 | const prompts = requests | 350 | const prompts = requests |
| ... | @@ -344,7 +352,8 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { | ... | @@ -344,7 +352,8 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { |
| 344 | .map( | 352 | .map( |
| 345 | (request) => (request.params as { prompt: Array<{ type: string; text: string }> }).prompt, | 353 | (request) => (request.params as { prompt: Array<{ type: string; text: string }> }).prompt, |
| 346 | ); | 354 | ); |
| 347 | assert.equal(prompts.length, 2); | 355 | assert.equal(prompts.length, 3); |
| 356 | assert.deepEqual(prompts[2], [{ type: "text", text: "/goal status" }]); | ||
| 348 | assert.deepEqual(prompts[0]?.[0], { type: "text", text: "First prompt" }); | 357 | assert.deepEqual(prompts[0]?.[0], { type: "text", text: "First prompt" }); |
| 349 | assert.include(prompts[0]?.[1]?.text, "Grok harness, as grok-mock-alt"); | 358 | assert.include(prompts[0]?.[1]?.text, "Grok harness, as grok-mock-alt"); |
| 350 | assert.deepEqual(prompts[1]?.[0], { type: "text", text: "Second prompt" }); | 359 | assert.deepEqual(prompts[1]?.[0], { type: "text", text: "Second prompt" }); |
| ... | @@ -571,13 +580,19 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { | ... | @@ -571,13 +580,19 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { |
| 571 | 580 | ||
| 572 | const runtimeEvents: ProviderRuntimeEvent[] = []; | 581 | const runtimeEvents: ProviderRuntimeEvent[] = []; |
| 573 | const turnCompleted = yield* Deferred.make<void>(); | 582 | const turnCompleted = yield* Deferred.make<void>(); |
| 583 | const secondTurnCompleted = yield* Deferred.make<void>(); | ||
| 574 | const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => | 584 | const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => |
| 575 | Effect.sync(() => { | 585 | Effect.sync(() => { |
| 576 | runtimeEvents.push(event); | 586 | runtimeEvents.push(event); |
| 577 | }).pipe( | 587 | }).pipe( |
| 578 | Effect.andThen( | 588 | Effect.andThen(() => |
| 579 | event.type === "turn.completed" | 589 | event.type === "turn.completed" |
| 580 | ? Deferred.succeed(turnCompleted, undefined) | 590 | ? Deferred.succeed( |
| 591 | runtimeEvents.filter((entry) => entry.type === "turn.completed").length === 2 | ||
| 592 | ? secondTurnCompleted | ||
| 593 | : turnCompleted, | ||
| 594 | undefined, | ||
| 595 | ) | ||
| 581 | : Effect.void, | 596 | : Effect.void, |
| 582 | ), | 597 | ), |
| 583 | ), | 598 | ), |
| ... | @@ -644,6 +659,44 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { | ... | @@ -644,6 +659,44 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { |
| 644 | assert.equal(readySession?.status, "ready"); | 659 | assert.equal(readySession?.status, "ready"); |
| 645 | assert.isUndefined(readySession?.activeTurnId); | 660 | assert.isUndefined(readySession?.activeTurnId); |
| 646 | 661 | ||
| 662 | const firstItemCompletion = runtimeEvents.findIndex( | ||
| 663 | (event) => | ||
| 664 | event.type === "item.completed" && event.payload.itemType === "assistant_message", | ||
| 665 | ); | ||
| 666 | assert.isAtLeast(firstItemCompletion, 0); | ||
| 667 | assert.isBelow(firstItemCompletion, terminalIndex); | ||
| 668 | |||
| 669 | const secondTurn = yield* adapter.sendTurn({ threadId, input: "/goal status" }); | ||
| 670 | yield* Deferred.await(secondTurnCompleted); | ||
| 671 | const assistantItems = runtimeEvents.filter( | ||
| 672 | (event) => | ||
| 673 | (event.type === "item.started" || event.type === "item.completed") && | ||
| 674 | event.payload.itemType === "assistant_message", | ||
| 675 | ); | ||
| 676 | const startedItems = assistantItems.filter((event) => event.type === "item.started"); | ||
| 677 | const completedItems = assistantItems.filter((event) => event.type === "item.completed"); | ||
| 678 | assert.equal(completedItems.length, startedItems.length); | ||
| 679 | assert.equal(new Set(startedItems.map((event) => event.itemId)).size, startedItems.length); | ||
| 680 | for (const turnId of [sendTurnResult.turnId, secondTurn.turnId]) { | ||
| 681 | assert.lengthOf( | ||
| 682 | startedItems.filter((event) => event.turnId === turnId), | ||
| 683 | 1, | ||
| 684 | ); | ||
| 685 | } | ||
| 686 | for (const started of startedItems) { | ||
| 687 | const completions = completedItems.filter((event) => event.itemId === started.itemId); | ||
| 688 | assert.lengthOf(completions, 1); | ||
| 689 | const completed = completions[0]!; | ||
| 690 | assert.equal(completed.turnId, started.turnId); | ||
| 691 | assert.isAbove(runtimeEvents.indexOf(completed), runtimeEvents.indexOf(started)); | ||
| 692 | assert.isBelow( | ||
| 693 | runtimeEvents.indexOf(completed), | ||
| 694 | runtimeEvents.findIndex( | ||
| 695 | (event) => event.type === "turn.completed" && event.turnId === started.turnId, | ||
| 696 | ), | ||
| 697 | ); | ||
| 698 | } | ||
| 699 | |||
| 647 | yield* Fiber.interrupt(runtimeEventsFiber); | 700 | yield* Fiber.interrupt(runtimeEventsFiber); |
| 648 | yield* adapter.stopSession(threadId); | 701 | yield* adapter.stopSession(threadId); |
| 649 | }), | 702 | }), |
| ... | @@ -1669,6 +1722,7 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { | ... | @@ -1669,6 +1722,7 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { |
| 1669 | const runtimeEvents: ProviderRuntimeEvent[] = []; | 1722 | const runtimeEvents: ProviderRuntimeEvent[] = []; |
| 1670 | const activeTurnIdRef = yield* Ref.make<TurnId | undefined>(undefined); | 1723 | const activeTurnIdRef = yield* Ref.make<TurnId | undefined>(undefined); |
| 1671 | const trailingChunkTurnId = yield* Deferred.make<TurnId>(); | 1724 | const trailingChunkTurnId = yield* Deferred.make<TurnId>(); |
| 1725 | let receivedText = ""; | ||
| 1672 | const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => | 1726 | const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => |
| 1673 | Effect.gen(function* () { | 1727 | Effect.gen(function* () { |
| 1674 | runtimeEvents.push(event); | 1728 | runtimeEvents.push(event); |
| ... | @@ -1678,7 +1732,11 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { | ... | @@ -1678,7 +1732,11 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { |
| 1678 | if (event.type === "turn.started") { | 1732 | if (event.type === "turn.started") { |
| 1679 | yield* Ref.set(activeTurnIdRef, event.turnId); | 1733 | yield* Ref.set(activeTurnIdRef, event.turnId); |
| 1680 | } | 1734 | } |
| 1681 | if (event.type !== "content.delta" || event.payload.delta !== "mock") { | 1735 | if (event.type !== "content.delta") { |
| 1736 | return; | ||
| 1737 | } | ||
| 1738 | receivedText += event.payload.delta; | ||
| 1739 | if (receivedText !== "hello from mock") { | ||
| 1682 | return; | 1740 | return; |
| 1683 | } | 1741 | } |
| 1684 | const turnId = event.turnId ?? (yield* Ref.get(activeTurnIdRef)); | 1742 | const turnId = event.turnId ?? (yield* Ref.get(activeTurnIdRef)); |
apps/server/src/provider/Layers/GrokAdapter.ts+19-6| ... | @@ -1505,6 +1505,13 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte | ... | @@ -1505,6 +1505,13 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte |
| 1505 | 1505 | ||
| 1506 | const sendTurn: GrokAdapterShape["sendTurn"] = (input) => | 1506 | const sendTurn: GrokAdapterShape["sendTurn"] = (input) => |
| 1507 | Effect.gen(function* () { | 1507 | Effect.gen(function* () { |
| 1508 | if (/^\/always-approve(?:\s|$)/i.test(input.input?.trim() ?? "")) { | ||
| 1509 | return yield* new ProviderAdapterRequestError({ | ||
| 1510 | provider: PROVIDER, | ||
| 1511 | method: "session/prompt", | ||
| 1512 | detail: "Change permissions with T3's permission selector instead of /always-approve.", | ||
| 1513 | }); | ||
| 1514 | } | ||
| 1508 | const prepared = yield* withThreadLock( | 1515 | const prepared = yield* withThreadLock( |
| 1509 | input.threadId, | 1516 | input.threadId, |
| 1510 | Effect.gen(function* () { | 1517 | Effect.gen(function* () { |
| ... | @@ -1616,11 +1623,15 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte | ... | @@ -1616,11 +1623,15 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte |
| 1616 | const displayModel = currentModelId | 1623 | const displayModel = currentModelId |
| 1617 | ? resolveGrokAcpBaseModelId(currentModelId) | 1624 | ? resolveGrokAcpBaseModelId(currentModelId) |
| 1618 | : undefined; | 1625 | : undefined; |
| 1619 | const runtimeInstructions = buildRuntimeInstructions({ | 1626 | // ACP slash commands must receive only their own arguments. |
| 1620 | harness: "Grok", | 1627 | const runtimeInstructions = |
| 1621 | model: displayModel, | 1628 | text && /^\/[^\s/]+(?:\s|$)/.test(text) |
| 1622 | reasoningEffort: normalizeGrokReasoningEffort(requestedTurnReasoningEffort), | 1629 | ? undefined |
| 1623 | }); | 1630 | : buildRuntimeInstructions({ |
| 1631 | harness: "Grok", | ||
| 1632 | model: displayModel, | ||
| 1633 | reasoningEffort: normalizeGrokReasoningEffort(requestedTurnReasoningEffort), | ||
| 1634 | }); | ||
| 1624 | for (let yieldAttempt = 0; yieldAttempt < 8; yieldAttempt += 1) { | 1635 | for (let yieldAttempt = 0; yieldAttempt < 8; yieldAttempt += 1) { |
| 1625 | yield* Effect.yieldNow; | 1636 | yield* Effect.yieldNow; |
| 1626 | } | 1637 | } |
| ... | @@ -1737,7 +1748,9 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte | ... | @@ -1737,7 +1748,9 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte |
| 1737 | { | 1748 | { |
| 1738 | prompt: [ | 1749 | prompt: [ |
| 1739 | ...prepared.promptParts, | 1750 | ...prepared.promptParts, |
| 1740 | { type: "text", text: prepared.runtimeInstructions }, | 1751 | ...(prepared.runtimeInstructions |
| 1752 | ? [{ type: "text" as const, text: prepared.runtimeInstructions }] | ||
| 1753 | : []), | ||
| 1741 | ], | 1754 | ], |
| 1742 | }, | 1755 | }, |
| 1743 | { dispatched }, | 1756 | { dispatched }, |
apps/server/src/provider/Layers/GrokProvider.test.ts+65| ... | @@ -14,6 +14,7 @@ import { | ... | @@ -14,6 +14,7 @@ import { |
| 14 | buildGrokModelsFromSessionModelState, | 14 | buildGrokModelsFromSessionModelState, |
| 15 | buildInitialGrokProviderSnapshot, | 15 | buildInitialGrokProviderSnapshot, |
| 16 | checkGrokProviderStatus, | 16 | checkGrokProviderStatus, |
| 17 | grokSlashCommandsFromInitialize, | ||
| 17 | parseGrokModelsCliOutput, | 18 | parseGrokModelsCliOutput, |
| 18 | } from "./GrokProvider.ts"; | 19 | } from "./GrokProvider.ts"; |
| 19 | import { execScriptSource, writeFakeCli } from "../../testUtils/fakeCli.ts"; | 20 | import { execScriptSource, writeFakeCli } from "../../testUtils/fakeCli.ts"; |
| ... | @@ -56,6 +57,69 @@ describe("parseGrokModelsCliOutput", () => { | ... | @@ -56,6 +57,69 @@ describe("parseGrokModelsCliOutput", () => { |
| 56 | }); | 57 | }); |
| 57 | }); | 58 | }); |
| 58 | 59 | ||
| 60 | describe("grokSlashCommandsFromInitialize", () => { | ||
| 61 | it("publishes native ACP commands and input hints without permission overrides", () => { | ||
| 62 | const commands = grokSlashCommandsFromInitialize({ | ||
| 63 | protocolVersion: 1, | ||
| 64 | _meta: { | ||
| 65 | availableCommands: [ | ||
| 66 | { name: "compact", description: "Compress history", input: { hint: "what to preserve" } }, | ||
| 67 | { | ||
| 68 | name: "always-approve", | ||
| 69 | description: "Skip permission prompts", | ||
| 70 | input: { hint: "on|off" }, | ||
| 71 | }, | ||
| 72 | { name: "context", description: "Show context usage", input: null }, | ||
| 73 | { name: "session-info", description: "Show session details" }, | ||
| 74 | { name: "deep-research", description: "Research a topic", input: { hint: "<query>" } }, | ||
| 75 | { name: "workflow", description: "Manage workflows", input: { hint: "runs" } }, | ||
| 76 | { name: "goal", description: "Manage an autonomous goal", input: { hint: "status" } }, | ||
| 77 | ], | ||
| 78 | }, | ||
| 79 | }); | ||
| 80 | expect(commands.map((command) => command.name)).toEqual([ | ||
| 81 | "compact", | ||
| 82 | "session-info", | ||
| 83 | "deep-research", | ||
| 84 | "workflow", | ||
| 85 | "goal", | ||
| 86 | ]); | ||
| 87 | expect(commands[0]?.input).toEqual({ hint: "what to preserve" }); | ||
| 88 | expect(commands[2]).toEqual({ | ||
| 89 | name: "deep-research", | ||
| 90 | description: "Research a topic", | ||
| 91 | input: { hint: "<query>" }, | ||
| 92 | }); | ||
| 93 | }); | ||
| 94 | |||
| 95 | it("keeps valid commands when other metadata entries are malformed", () => { | ||
| 96 | const commands = grokSlashCommandsFromInitialize({ | ||
| 97 | protocolVersion: 1, | ||
| 98 | _meta: { | ||
| 99 | availableCommands: [ | ||
| 100 | null, | ||
| 101 | { name: "broken", description: 42 }, | ||
| 102 | { name: " ", description: "Empty name" }, | ||
| 103 | { name: " session-info ", description: " Session details " }, | ||
| 104 | { name: "session-info", description: "Updated session details" }, | ||
| 105 | ], | ||
| 106 | }, | ||
| 107 | }); | ||
| 108 | expect(commands.map((command) => command.name)).toEqual(["compact", "session-info"]); | ||
| 109 | expect(commands[1]?.description).toBe("Updated session details"); | ||
| 110 | }); | ||
| 111 | |||
| 112 | it("keeps compact available for older agents without command metadata", () => { | ||
| 113 | for (const _meta of [undefined, {}, { availableCommands: "invalid" }]) { | ||
| 114 | expect( | ||
| 115 | grokSlashCommandsFromInitialize({ protocolVersion: 1, ...(_meta ? { _meta } : {}) }).map( | ||
| 116 | (command) => command.name, | ||
| 117 | ), | ||
| 118 | ).toEqual(["compact"]); | ||
| 119 | } | ||
| 120 | }); | ||
| 121 | }); | ||
| 122 | |||
| 59 | describe("buildGrokModelsFromSessionModelState", () => { | 123 | describe("buildGrokModelsFromSessionModelState", () => { |
| 60 | it("marks the agent's current model as default and keeps reasoning options", () => { | 124 | it("marks the agent's current model as default and keeps reasoning options", () => { |
| 61 | const models = buildGrokModelsFromSessionModelState({ | 125 | const models = buildGrokModelsFromSessionModelState({ |
| ... | @@ -438,6 +502,7 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { | ... | @@ -438,6 +502,7 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { |
| 438 | ["grok-4.5", false], | 502 | ["grok-4.5", false], |
| 439 | ]); | 503 | ]); |
| 440 | expect(snapshot.message).toContain("ACP initialize failed"); | 504 | expect(snapshot.message).toContain("ACP initialize failed"); |
| 505 | expect(snapshot.slashCommands.map((command) => command.name)).toEqual(["compact"]); | ||
| 441 | }), | 506 | }), |
| 442 | ); | 507 | ); |
| 443 | 508 |
apps/server/src/provider/Layers/GrokProvider.ts+43-7| ... | @@ -5,8 +5,9 @@ import { | ... | @@ -5,8 +5,9 @@ import { |
| 5 | type ServerProvider, | 5 | type ServerProvider, |
| 6 | type ServerProviderAuth, | 6 | type ServerProviderAuth, |
| 7 | type ServerProviderModel, | 7 | type ServerProviderModel, |
| 8 | type ServerProviderSlashCommand, | ||
| 8 | } from "@t3tools/contracts"; | 9 | } from "@t3tools/contracts"; |
| 9 | import type * as EffectAcpSchema from "effect-acp/schema"; | 10 | import * as EffectAcpSchema from "effect-acp/schema"; |
| 10 | import { causeErrorTag } from "@t3tools/shared/observability"; | 11 | import { causeErrorTag } from "@t3tools/shared/observability"; |
| 11 | import * as Crypto from "effect/Crypto"; | 12 | import * as Crypto from "effect/Crypto"; |
| 12 | import * as DateTime from "effect/DateTime"; | 13 | import * as DateTime from "effect/DateTime"; |
| ... | @@ -14,6 +15,7 @@ import * as Effect from "effect/Effect"; | ... | @@ -14,6 +15,7 @@ import * as Effect from "effect/Effect"; |
| 14 | import * as Exit from "effect/Exit"; | 15 | import * as Exit from "effect/Exit"; |
| 15 | import * as Option from "effect/Option"; | 16 | import * as Option from "effect/Option"; |
| 16 | import * as Result from "effect/Result"; | 17 | import * as Result from "effect/Result"; |
| 18 | import * as Schema from "effect/Schema"; | ||
| 17 | import { HttpClient } from "effect/unstable/http"; | 19 | import { HttpClient } from "effect/unstable/http"; |
| 18 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | 20 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 19 | import { createModelCapabilities } from "@t3tools/shared/model"; | 21 | import { createModelCapabilities } from "@t3tools/shared/model"; |
| ... | @@ -307,11 +309,41 @@ const runGrokCliCommand = ( | ... | @@ -307,11 +309,41 @@ const runGrokCliCommand = ( |
| 307 | ); | 309 | ); |
| 308 | }); | 310 | }); |
| 309 | 311 | ||
| 312 | const decodeAvailableCommands = Schema.decodeUnknownOption(Schema.Array(Schema.Unknown)); | ||
| 313 | const decodeAvailableCommand = Schema.decodeUnknownOption(EffectAcpSchema.AvailableCommand); | ||
| 314 | |||
| 315 | export function grokSlashCommandsFromInitialize( | ||
| 316 | initialized: EffectAcpSchema.InitializeResponse, | ||
| 317 | ): ReadonlyArray<ServerProviderSlashCommand> { | ||
| 318 | const commands = decodeAvailableCommands(initialized._meta?.availableCommands); | ||
| 319 | const byName = new Map<string, ServerProviderSlashCommand>([ | ||
| 320 | [COMPACT_SLASH_COMMAND.name, COMPACT_SLASH_COMMAND], | ||
| 321 | ]); | ||
| 322 | for (const entry of Option.getOrElse(commands, () => [])) { | ||
| 323 | const decoded = decodeAvailableCommand(entry); | ||
| 324 | if (Option.isNone(decoded)) continue; | ||
| 325 | const command = decoded.value; | ||
| 326 | const name = command.name.trim(); | ||
| 327 | // Permission changes must go through T3 so the client and provider agree. | ||
| 328 | if (!name || name.toLowerCase() === "always-approve") continue; | ||
| 329 | // Grok advertises /context, but its ACP handler completes without emitting output. | ||
| 330 | if (name.toLowerCase() === "context") continue; | ||
| 331 | const description = command.description.trim(); | ||
| 332 | const hint = command.input?.hint.trim(); | ||
| 333 | byName.set(name, { | ||
| 334 | name, | ||
| 335 | ...(description ? { description } : {}), | ||
| 336 | ...(hint ? { input: { hint } } : {}), | ||
| 337 | }); | ||
| 338 | } | ||
| 339 | return [...byName.values()]; | ||
| 340 | } | ||
| 341 | |||
| 310 | /** | 342 | /** |
| 311 | * Reads model metadata from `initialize._meta.modelState`. This never calls `authenticate` | 343 | * Reads model and command metadata from `initialize._meta`. This never calls `authenticate` |
| 312 | * or `session/new`, so it cannot open a browser login or boot the workspace's MCP servers. | 344 | * or `session/new`, so it cannot open a browser login or boot the workspace's MCP servers. |
| 313 | */ | 345 | */ |
| 314 | const discoverGrokModelsViaAcpInitialize = ( | 346 | const discoverGrokMetadataViaAcpInitialize = ( |
| 315 | grokSettings: GrokSettings, | 347 | grokSettings: GrokSettings, |
| 316 | environment: NodeJS.ProcessEnv, | 348 | environment: NodeJS.ProcessEnv, |
| 317 | ) => | 349 | ) => |
| ... | @@ -325,7 +357,10 @@ const discoverGrokModelsViaAcpInitialize = ( | ... | @@ -325,7 +357,10 @@ const discoverGrokModelsViaAcpInitialize = ( |
| 325 | clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, | 357 | clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, |
| 326 | }); | 358 | }); |
| 327 | const initialized = yield* acp.initialize(); | 359 | const initialized = yield* acp.initialize(); |
| 328 | return buildGrokModelsFromSessionModelState(sessionModelStateFromInitialize(initialized)); | 360 | return { |
| 361 | models: buildGrokModelsFromSessionModelState(sessionModelStateFromInitialize(initialized)), | ||
| 362 | slashCommands: grokSlashCommandsFromInitialize(initialized), | ||
| 363 | }; | ||
| 329 | }).pipe(Effect.scoped); | 364 | }).pipe(Effect.scoped); |
| 330 | 365 | ||
| 331 | export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(function* ( | 366 | export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(function* ( |
| ... | @@ -461,11 +496,12 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func | ... | @@ -461,11 +496,12 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func |
| 461 | Effect.orElseSucceed(() => []), | 496 | Effect.orElseSucceed(() => []), |
| 462 | ); | 497 | ); |
| 463 | 498 | ||
| 464 | const acpExit = yield* discoverGrokModelsViaAcpInitialize(grokSettings, environment).pipe( | 499 | const acpExit = yield* discoverGrokMetadataViaAcpInitialize(grokSettings, environment).pipe( |
| 465 | Effect.timeoutOption(GROK_ACP_INITIALIZE_TIMEOUT_MS), | 500 | Effect.timeoutOption(GROK_ACP_INITIALIZE_TIMEOUT_MS), |
| 466 | Effect.exit, | 501 | Effect.exit, |
| 467 | ); | 502 | ); |
| 468 | const acpModels = Exit.isSuccess(acpExit) ? Option.getOrElse(acpExit.value, () => []) : []; | 503 | const acpMetadata = Exit.isSuccess(acpExit) ? Option.getOrUndefined(acpExit.value) : undefined; |
| 504 | const acpModels = acpMetadata?.models ?? []; | ||
| 469 | const acpFailed = Exit.isFailure(acpExit) || Option.isNone(acpExit.value); | 505 | const acpFailed = Exit.isFailure(acpExit) || Option.isNone(acpExit.value); |
| 470 | if (acpFailed) { | 506 | if (acpFailed) { |
| 471 | yield* Effect.logWarning("Grok ACP initialize probe failed or timed out.", { | 507 | yield* Effect.logWarning("Grok ACP initialize probe failed or timed out.", { |
| ... | @@ -502,7 +538,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func | ... | @@ -502,7 +538,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func |
| 502 | checkedAt, | 538 | checkedAt, |
| 503 | models, | 539 | models, |
| 504 | skills, | 540 | skills, |
| 505 | slashCommands: [COMPACT_SLASH_COMMAND], | 541 | slashCommands: acpMetadata?.slashCommands ?? [COMPACT_SLASH_COMMAND], |
| 506 | probe: { | 542 | probe: { |
| 507 | installed: true, | 543 | installed: true, |
| 508 | version, | 544 | version, |
apps/server/src/provider/Layers/OpenCodeAdapter.test.ts+272| ... | @@ -88,6 +88,10 @@ const runtimeMock = { | ... | @@ -88,6 +88,10 @@ const runtimeMock = { |
| 88 | messageCalls: [] as Array<{ sessionID: string; messageID: string }>, | 88 | messageCalls: [] as Array<{ sessionID: string; messageID: string }>, |
| 89 | messageFailures: 0, | 89 | messageFailures: 0, |
| 90 | promptCalls: [] as Array<unknown>, | 90 | promptCalls: [] as Array<unknown>, |
| 91 | commandCalls: [] as Array<Record<string, unknown>>, | ||
| 92 | commandImplementation: null as | ||
| 93 | | ((input: Record<string, unknown>, signal?: AbortSignal) => Promise<void>) | ||
| 94 | | null, | ||
| 91 | summarizeCalls: [] as Array<unknown>, | 95 | summarizeCalls: [] as Array<unknown>, |
| 92 | promptAsyncError: null as Error | null, | 96 | promptAsyncError: null as Error | null, |
| 93 | promptAsyncImplementation: null as (() => Promise<void>) | null, | 97 | promptAsyncImplementation: null as (() => Promise<void>) | null, |
| ... | @@ -150,6 +154,8 @@ const runtimeMock = { | ... | @@ -150,6 +154,8 @@ const runtimeMock = { |
| 150 | this.state.messageCalls.length = 0; | 154 | this.state.messageCalls.length = 0; |
| 151 | this.state.messageFailures = 0; | 155 | this.state.messageFailures = 0; |
| 152 | this.state.promptCalls.length = 0; | 156 | this.state.promptCalls.length = 0; |
| 157 | this.state.commandCalls.length = 0; | ||
| 158 | this.state.commandImplementation = null; | ||
| 153 | this.state.summarizeCalls.length = 0; | 159 | this.state.summarizeCalls.length = 0; |
| 154 | this.state.promptAsyncError = null; | 160 | this.state.promptAsyncError = null; |
| 155 | this.state.promptAsyncImplementation = null; | 161 | this.state.promptAsyncImplementation = null; |
| ... | @@ -236,6 +242,11 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { | ... | @@ -236,6 +242,11 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { |
| 236 | runOpenCodeCommand: () => Effect.succeed({ stdout: "", stderr: "", code: 0 }), | 242 | runOpenCodeCommand: () => Effect.succeed({ stdout: "", stderr: "", code: 0 }), |
| 237 | createOpenCodeSdkClient: ({ baseUrl, serverPassword }) => | 243 | createOpenCodeSdkClient: ({ baseUrl, serverPassword }) => |
| 238 | ({ | 244 | ({ |
| 245 | command: { | ||
| 246 | list: async () => ({ | ||
| 247 | data: [{ name: "review", source: "command", hints: ["$ARGUMENTS"] }], | ||
| 248 | }), | ||
| 249 | }, | ||
| 239 | session: { | 250 | session: { |
| 240 | create: async (input: Record<string, unknown>) => { | 251 | create: async (input: Record<string, unknown>) => { |
| 241 | runtimeMock.state.sessionCreateUrls.push(baseUrl); | 252 | runtimeMock.state.sessionCreateUrls.push(baseUrl); |
| ... | @@ -354,6 +365,10 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { | ... | @@ -354,6 +365,10 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { |
| 354 | : { "http://127.0.0.1:9999/session": { type: "busy" as const } }, | 365 | : { "http://127.0.0.1:9999/session": { type: "busy" as const } }, |
| 355 | }; | 366 | }; |
| 356 | }, | 367 | }, |
| 368 | command: async (input: Record<string, unknown>, options?: { signal?: AbortSignal }) => { | ||
| 369 | runtimeMock.state.commandCalls.push(input); | ||
| 370 | await runtimeMock.state.commandImplementation?.(input, options?.signal); | ||
| 371 | }, | ||
| 357 | promptAsync: async (input: unknown) => { | 372 | promptAsync: async (input: unknown) => { |
| 358 | runtimeMock.state.promptCalls.push(input); | 373 | runtimeMock.state.promptCalls.push(input); |
| 359 | await runtimeMock.state.promptAsyncImplementation?.(); | 374 | await runtimeMock.state.promptAsyncImplementation?.(); |
| ... | @@ -1443,6 +1458,263 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { | ... | @@ -1443,6 +1458,263 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { |
| 1443 | }), | 1458 | }), |
| 1444 | ); | 1459 | ); |
| 1445 | 1460 | ||
| 1461 | it.effect("admits native commands before generation completes and keeps them interruptible", () => | ||
| 1462 | Effect.gen(function* () { | ||
| 1463 | const adapter = yield* OpenCodeAdapter; | ||
| 1464 | const threadId = asThreadId("thread-native-command"); | ||
| 1465 | const publish = makeOpenCodeEventQueue(); | ||
| 1466 | const completion = promiseWithResolvers<void>(); | ||
| 1467 | let responseSettled = false; | ||
| 1468 | runtimeMock.state.commandImplementation = async (input) => { | ||
| 1469 | publish({ | ||
| 1470 | type: "message.updated", | ||
| 1471 | properties: { sessionID: input.sessionID, info: { id: input.messageID, role: "user" } }, | ||
| 1472 | }); | ||
| 1473 | await completion.promise; | ||
| 1474 | responseSettled = true; | ||
| 1475 | }; | ||
| 1476 | runtimeMock.state.abortImplementation = async () => { | ||
| 1477 | completion.resolve(undefined); | ||
| 1478 | }; | ||
| 1479 | yield* adapter.startSession({ | ||
| 1480 | provider: ProviderDriverKind.make("opencode"), | ||
| 1481 | threadId, | ||
| 1482 | runtimeMode: "full-access", | ||
| 1483 | }); | ||
| 1484 | const result = yield* adapter.sendTurn({ | ||
| 1485 | threadId, | ||
| 1486 | input: "/review main\nfocus on authentication", | ||
| 1487 | modelSelection: createModelSelection(ProviderInstanceId.make("opencode"), "openai/gpt-5", [ | ||
| 1488 | { id: "agent", value: "build" }, | ||
| 1489 | { id: "variant", value: "high" }, | ||
| 1490 | ]), | ||
| 1491 | }); | ||
| 1492 | NodeAssert.equal(responseSettled, false); | ||
| 1493 | const { messageID, ...command } = runtimeMock.state.commandCalls[0]!; | ||
| 1494 | NodeAssert.equal(typeof messageID, "string"); | ||
| 1495 | NodeAssert.deepEqual(command, { | ||
| 1496 | sessionID: "http://127.0.0.1:9999/session", | ||
| 1497 | command: "review", | ||
| 1498 | arguments: "main\nfocus on authentication", | ||
| 1499 | model: "openai/gpt-5", | ||
| 1500 | agent: "build", | ||
| 1501 | variant: "high", | ||
| 1502 | parts: [], | ||
| 1503 | }); | ||
| 1504 | NodeAssert.equal(runtimeMock.state.promptCalls.length, 0); | ||
| 1505 | yield* advanceTestClock(11_000); | ||
| 1506 | NodeAssert.equal(runtimeMock.state.abortCalls.length, 0); | ||
| 1507 | yield* adapter.interruptTurn(threadId, result.turnId); | ||
| 1508 | NodeAssert.equal(runtimeMock.state.abortCalls.length, 1); | ||
| 1509 | NodeAssert.equal((yield* adapter.listSessions())[0]?.activeTurnId, undefined); | ||
| 1510 | yield* adapter.stopSession(threadId); | ||
| 1511 | }), | ||
| 1512 | ); | ||
| 1513 | |||
| 1514 | it.effect("recovers a native command receipt when the user-message event is lost", () => | ||
| 1515 | Effect.gen(function* () { | ||
| 1516 | const adapter = yield* OpenCodeAdapter; | ||
| 1517 | const threadId = asThreadId("thread-native-command-recovered"); | ||
| 1518 | const started = promiseWithResolvers<void>(); | ||
| 1519 | const completion = promiseWithResolvers<void>(); | ||
| 1520 | runtimeMock.state.sessionStatus = "busy"; | ||
| 1521 | runtimeMock.state.commandImplementation = async (input) => { | ||
| 1522 | NodeAssert.ok(typeof input.messageID === "string"); | ||
| 1523 | runtimeMock.state.messages.push({ info: { id: input.messageID, role: "user" }, parts: [] }); | ||
| 1524 | started.resolve(undefined); | ||
| 1525 | await completion.promise; | ||
| 1526 | }; | ||
| 1527 | runtimeMock.state.abortImplementation = async () => { | ||
| 1528 | completion.resolve(undefined); | ||
| 1529 | }; | ||
| 1530 | yield* adapter.startSession({ | ||
| 1531 | provider: ProviderDriverKind.make("opencode"), | ||
| 1532 | threadId, | ||
| 1533 | runtimeMode: "full-access", | ||
| 1534 | }); | ||
| 1535 | const sendFiber = yield* adapter | ||
| 1536 | .sendTurn({ | ||
| 1537 | threadId, | ||
| 1538 | input: "/review", | ||
| 1539 | modelSelection: createModelSelection(ProviderInstanceId.make("opencode"), "openai/gpt-5"), | ||
| 1540 | }) | ||
| 1541 | .pipe(Effect.forkChild); | ||
| 1542 | yield* Effect.promise(() => started.promise); | ||
| 1543 | yield* advanceTestClock(250); | ||
| 1544 | const result = yield* Fiber.join(sendFiber); | ||
| 1545 | NodeAssert.ok( | ||
| 1546 | runtimeMock.state.messageCalls.some( | ||
| 1547 | ({ messageID }) => messageID === runtimeMock.state.commandCalls[0]?.messageID, | ||
| 1548 | ), | ||
| 1549 | ); | ||
| 1550 | yield* advanceTestClock(11_000); | ||
| 1551 | NodeAssert.equal(runtimeMock.state.abortCalls.length, 0); | ||
| 1552 | NodeAssert.equal( | ||
| 1553 | (yield* adapter.listSessions()).find((session) => session.threadId === threadId) | ||
| 1554 | ?.activeTurnId, | ||
| 1555 | result.turnId, | ||
| 1556 | ); | ||
| 1557 | yield* adapter.interruptTurn(threadId, result.turnId); | ||
| 1558 | NodeAssert.equal(runtimeMock.state.abortCalls.length, 1); | ||
| 1559 | yield* adapter.stopSession(threadId); | ||
| 1560 | }), | ||
| 1561 | ); | ||
| 1562 | |||
| 1563 | it.effect("bounds native admission recovery when timeout cleanup cannot abort the session", () => | ||
| 1564 | Effect.gen(function* () { | ||
| 1565 | const adapter = yield* OpenCodeAdapter; | ||
| 1566 | const threadId = asThreadId("thread-native-command-timeout-abort-failure"); | ||
| 1567 | const started = promiseWithResolvers<void>(); | ||
| 1568 | runtimeMock.state.commandImplementation = async () => { | ||
| 1569 | started.resolve(undefined); | ||
| 1570 | await new Promise<void>(() => {}); | ||
| 1571 | }; | ||
| 1572 | runtimeMock.state.abortImplementation = async () => { | ||
| 1573 | throw new Error("abort failed"); | ||
| 1574 | }; | ||
| 1575 | runtimeMock.state.sessionStatus = "idle"; | ||
| 1576 | yield* adapter.startSession({ | ||
| 1577 | provider: ProviderDriverKind.make("opencode"), | ||
| 1578 | threadId, | ||
| 1579 | runtimeMode: "full-access", | ||
| 1580 | }); | ||
| 1581 | const exitedFiber = yield* adapter.streamEvents.pipe( | ||
| 1582 | Stream.filter((event) => event.threadId === threadId && event.type === "session.exited"), | ||
| 1583 | Stream.runHead, | ||
| 1584 | Effect.forkChild, | ||
| 1585 | ); | ||
| 1586 | const sendFiber = yield* adapter | ||
| 1587 | .sendTurn({ | ||
| 1588 | threadId, | ||
| 1589 | input: "/review", | ||
| 1590 | modelSelection: createModelSelection(ProviderInstanceId.make("opencode"), "openai/gpt-5"), | ||
| 1591 | }) | ||
| 1592 | .pipe(Effect.exit, Effect.forkChild); | ||
| 1593 | yield* Effect.promise(() => started.promise); | ||
| 1594 | yield* advanceTestClock(10_000); | ||
| 1595 | NodeAssert.equal(Exit.isFailure(yield* Fiber.join(sendFiber)), true); | ||
| 1596 | yield* advanceTestClock(6_000); | ||
| 1597 | const exited = Option.getOrThrow(yield* Fiber.join(exitedFiber)); | ||
| 1598 | NodeAssert.ok(exited.type === "session.exited"); | ||
| 1599 | NodeAssert.equal(exited.payload.exitKind, "error"); | ||
| 1600 | NodeAssert.ok(runtimeMock.state.sessionStatusCalls > 0); | ||
| 1601 | NodeAssert.equal( | ||
| 1602 | (yield* adapter.listSessions()).some((session) => session.threadId === threadId), | ||
| 1603 | false, | ||
| 1604 | ); | ||
| 1605 | const messageCalls = runtimeMock.state.messageCalls.length; | ||
| 1606 | yield* advanceTestClock(5_000); | ||
| 1607 | NodeAssert.equal(runtimeMock.state.messageCalls.length, messageCalls); | ||
| 1608 | }), | ||
| 1609 | ); | ||
| 1610 | |||
| 1611 | for (const nativeStartsTurn of [true, false]) { | ||
| 1612 | it.effect( | ||
| 1613 | `reports a late native command failure after another steer (starts turn: ${nativeStartsTurn})`, | ||
| 1614 | () => | ||
| 1615 | Effect.gen(function* () { | ||
| 1616 | const adapter = yield* OpenCodeAdapter; | ||
| 1617 | const threadId = asThreadId(`thread-command-late-error-${nativeStartsTurn}`); | ||
| 1618 | const publish = makeOpenCodeEventQueue(); | ||
| 1619 | const completion = promiseWithResolvers<void>(); | ||
| 1620 | const modelSelection = createModelSelection( | ||
| 1621 | ProviderInstanceId.make("opencode"), | ||
| 1622 | "openai/gpt-5", | ||
| 1623 | ); | ||
| 1624 | runtimeMock.state.commandImplementation = async (input) => { | ||
| 1625 | publish({ | ||
| 1626 | type: "message.updated", | ||
| 1627 | properties: { | ||
| 1628 | sessionID: input.sessionID, | ||
| 1629 | info: { id: input.messageID, role: "user" }, | ||
| 1630 | }, | ||
| 1631 | }); | ||
| 1632 | await completion.promise; | ||
| 1633 | }; | ||
| 1634 | yield* adapter.startSession({ | ||
| 1635 | provider: ProviderDriverKind.make("opencode"), | ||
| 1636 | threadId, | ||
| 1637 | runtimeMode: "full-access", | ||
| 1638 | }); | ||
| 1639 | if (!nativeStartsTurn) | ||
| 1640 | yield* adapter.sendTurn({ threadId, input: "Start work", modelSelection }); | ||
| 1641 | const command = yield* adapter.sendTurn({ threadId, input: "/review", modelSelection }); | ||
| 1642 | yield* adapter.sendTurn({ threadId, input: "Focus on authentication", modelSelection }); | ||
| 1643 | const warningFiber = yield* adapter.streamEvents.pipe( | ||
| 1644 | Stream.filter( | ||
| 1645 | (event) => event.threadId === threadId && event.type === "runtime.warning", | ||
| 1646 | ), | ||
| 1647 | Stream.runHead, | ||
| 1648 | Effect.forkChild, | ||
| 1649 | ); | ||
| 1650 | completion.reject(new Error("command failed after admission")); | ||
| 1651 | const warning = yield* Fiber.join(warningFiber); | ||
| 1652 | NodeAssert.equal(warning._tag, "Some"); | ||
| 1653 | if (warning._tag === "Some" && warning.value.type === "runtime.warning") { | ||
| 1654 | NodeAssert.equal(warning.value.payload.detail, "command failed after admission"); | ||
| 1655 | } | ||
| 1656 | const session = (yield* adapter.listSessions()).find( | ||
| 1657 | (entry) => entry.threadId === threadId, | ||
| 1658 | ); | ||
| 1659 | NodeAssert.equal(session?.activeTurnId, command.turnId); | ||
| 1660 | yield* adapter.stopSession(threadId); | ||
| 1661 | }), | ||
| 1662 | ); | ||
| 1663 | } | ||
| 1664 | |||
| 1665 | it.effect("surfaces native command rejection and leaves the session ready", () => | ||
| 1666 | Effect.gen(function* () { | ||
| 1667 | const adapter = yield* OpenCodeAdapter; | ||
| 1668 | const threadId = asThreadId("thread-native-command-error"); | ||
| 1669 | runtimeMock.state.commandImplementation = async () => { | ||
| 1670 | throw new Error("command unavailable"); | ||
| 1671 | }; | ||
| 1672 | yield* adapter.startSession({ | ||
| 1673 | provider: ProviderDriverKind.make("opencode"), | ||
| 1674 | threadId, | ||
| 1675 | runtimeMode: "full-access", | ||
| 1676 | }); | ||
| 1677 | const error = yield* adapter | ||
| 1678 | .sendTurn({ | ||
| 1679 | threadId, | ||
| 1680 | input: "/review", | ||
| 1681 | modelSelection: createModelSelection(ProviderInstanceId.make("opencode"), "openai/gpt-5"), | ||
| 1682 | }) | ||
| 1683 | .pipe(Effect.flip); | ||
| 1684 | NodeAssert.equal(error._tag, "ProviderAdapterRequestError"); | ||
| 1685 | if (error._tag !== "ProviderAdapterRequestError") throw new Error("Unexpected error type"); | ||
| 1686 | NodeAssert.equal(error.method, "session.command"); | ||
| 1687 | NodeAssert.equal(error.detail, "command unavailable"); | ||
| 1688 | NodeAssert.equal((yield* adapter.listSessions())[0]?.status, "ready"); | ||
| 1689 | yield* adapter.stopSession(threadId); | ||
| 1690 | }), | ||
| 1691 | ); | ||
| 1692 | |||
| 1693 | it.effect("keeps unknown slash text on the ordinary prompt path", () => | ||
| 1694 | Effect.gen(function* () { | ||
| 1695 | const adapter = yield* OpenCodeAdapter; | ||
| 1696 | const threadId = asThreadId("thread-unknown-command"); | ||
| 1697 | yield* adapter.startSession({ | ||
| 1698 | provider: ProviderDriverKind.make("opencode"), | ||
| 1699 | threadId, | ||
| 1700 | runtimeMode: "full-access", | ||
| 1701 | }); | ||
| 1702 | yield* adapter.sendTurn({ | ||
| 1703 | threadId, | ||
| 1704 | input: "/unknown explain this", | ||
| 1705 | modelSelection: createModelSelection(ProviderInstanceId.make("opencode"), "openai/gpt-5"), | ||
| 1706 | }); | ||
| 1707 | NodeAssert.equal(runtimeMock.state.commandCalls.length, 0); | ||
| 1708 | const prompt = runtimeMock.state.promptCalls[0] as { parts: unknown; system: string }; | ||
| 1709 | NodeAssert.deepEqual(prompt.parts, [{ type: "text", text: "/unknown explain this" }]); | ||
| 1710 | NodeAssert.equal( | ||
| 1711 | prompt.system, | ||
| 1712 | buildRuntimeInstructions({ harness: "OpenCode", model: "openai/gpt-5" }), | ||
| 1713 | ); | ||
| 1714 | yield* adapter.stopSession(threadId); | ||
| 1715 | }), | ||
| 1716 | ); | ||
| 1717 | |||
| 1446 | it.effect("rolls back session state when sendTurn fails before OpenCode accepts the prompt", () => | 1718 | it.effect("rolls back session state when sendTurn fails before OpenCode accepts the prompt", () => |
| 1447 | Effect.gen(function* () { | 1719 | Effect.gen(function* () { |
| 1448 | const adapter = yield* OpenCodeAdapter; | 1720 | const adapter = yield* OpenCodeAdapter; |
apps/server/src/provider/Layers/OpenCodeAdapter.ts+134-28| ... | @@ -51,6 +51,7 @@ import { | ... | @@ -51,6 +51,7 @@ import { |
| 51 | OpenCodeRuntimeError, | 51 | OpenCodeRuntimeError, |
| 52 | openCodeQuestionId, | 52 | openCodeQuestionId, |
| 53 | openCodeRuntimeErrorDetail, | 53 | openCodeRuntimeErrorDetail, |
| 54 | loadOpenCodeCommands, | ||
| 54 | parseOpenCodeModelSlug, | 55 | parseOpenCodeModelSlug, |
| 55 | runOpenCodeSdk, | 56 | runOpenCodeSdk, |
| 56 | toOpenCodeFileParts, | 57 | toOpenCodeFileParts, |
| ... | @@ -215,6 +216,7 @@ interface OpenCodePromptAdmission { | ... | @@ -215,6 +216,7 @@ interface OpenCodePromptAdmission { |
| 215 | readonly generation: number; | 216 | readonly generation: number; |
| 216 | readonly turnId: TurnId; | 217 | readonly turnId: TurnId; |
| 217 | readonly messageId: string; | 218 | readonly messageId: string; |
| 219 | requiresMessageReceipt: boolean; | ||
| 218 | readonly priorAwaitingBusy: boolean; | 220 | readonly priorAwaitingBusy: boolean; |
| 219 | readonly priorIdle: { readonly turnId: TurnId; readonly raw: unknown } | undefined; | 221 | readonly priorIdle: { readonly turnId: TurnId; readonly raw: unknown } | undefined; |
| 220 | idleDuringAdmission: { readonly turnId: TurnId; readonly raw: unknown } | undefined; | 222 | idleDuringAdmission: { readonly turnId: TurnId; readonly raw: unknown } | undefined; |
| ... | @@ -225,6 +227,7 @@ interface OpenCodePromptAdmission { | ... | @@ -225,6 +227,7 @@ interface OpenCodePromptAdmission { |
| 225 | accepted: boolean; | 227 | accepted: boolean; |
| 226 | cancelled: boolean; | 228 | cancelled: boolean; |
| 227 | readonly acceptance: Deferred.Deferred<void>; | 229 | readonly acceptance: Deferred.Deferred<void>; |
| 230 | readonly messageReceipt: Deferred.Deferred<void>; | ||
| 228 | readonly submissionSettled: Deferred.Deferred<void>; | 231 | readonly submissionSettled: Deferred.Deferred<void>; |
| 229 | promptFiber?: Fiber.Fiber<void, ProviderAdapterRequestError>; | 232 | promptFiber?: Fiber.Fiber<void, ProviderAdapterRequestError>; |
| 230 | recoveryFiber?: Fiber.Fiber<void, never>; | 233 | recoveryFiber?: Fiber.Fiber<void, never>; |
| ... | @@ -362,6 +365,7 @@ interface OpenCodeSessionContext { | ... | @@ -362,6 +365,7 @@ interface OpenCodeSessionContext { |
| 362 | pendingRequestRecovery: OpenCodePendingRequestRecovery | undefined; | 365 | pendingRequestRecovery: OpenCodePendingRequestRecovery | undefined; |
| 363 | promptGeneration: number; | 366 | promptGeneration: number; |
| 364 | promptAdmission: OpenCodePromptAdmission | undefined; | 367 | promptAdmission: OpenCodePromptAdmission | undefined; |
| 368 | readonly commandFibers: Set<Fiber.Fiber<void, ProviderAdapterRequestError>>; | ||
| 365 | readonly promptSemaphore: Semaphore.Semaphore; | 369 | readonly promptSemaphore: Semaphore.Semaphore; |
| 366 | readonly firstConnection: Deferred.Deferred<void, ProviderAdapterRequestError>; | 370 | readonly firstConnection: Deferred.Deferred<void, ProviderAdapterRequestError>; |
| 367 | /** | 371 | /** |
| ... | @@ -1350,8 +1354,14 @@ export function makeOpenCodeAdapter( | ... | @@ -1350,8 +1354,14 @@ export function makeOpenCodeAdapter( |
| 1350 | return; | 1354 | return; |
| 1351 | } | 1355 | } |
| 1352 | const recover = Effect.gen(function* () { | 1356 | const recover = Effect.gen(function* () { |
| 1353 | yield* Deferred.await(promptAdmission.acceptance); | 1357 | if (!promptAdmission.requiresMessageReceipt) { |
| 1354 | for (let retryCount = 0; retryCount < 5; retryCount += 1) { | 1358 | yield* Deferred.await(promptAdmission.acceptance); |
| 1359 | } | ||
| 1360 | for ( | ||
| 1361 | let retryCount = 0; | ||
| 1362 | retryCount < 5 || (promptAdmission.requiresMessageReceipt && !promptAdmission.accepted); | ||
| 1363 | retryCount += 1 | ||
| 1364 | ) { | ||
| 1355 | if ( | 1365 | if ( |
| 1356 | context.promptAdmission !== promptAdmission || | 1366 | context.promptAdmission !== promptAdmission || |
| 1357 | context.activeTurnId !== promptAdmission.turnId || | 1367 | context.activeTurnId !== promptAdmission.turnId || |
| ... | @@ -1386,11 +1396,23 @@ export function makeOpenCodeAdapter( | ... | @@ -1386,11 +1396,23 @@ export function makeOpenCodeAdapter( |
| 1386 | const message = Option.isSome(response) ? response.value.data : undefined; | 1396 | const message = Option.isSome(response) ? response.value.data : undefined; |
| 1387 | if (message?.info.id === promptAdmission.messageId && message.info.role === "user") { | 1397 | if (message?.info.id === promptAdmission.messageId && message.info.role === "user") { |
| 1388 | promptAdmission.messageObserved = true; | 1398 | promptAdmission.messageObserved = true; |
| 1399 | yield* Deferred.succeed(promptAdmission.messageReceipt, undefined); | ||
| 1389 | context.messageRoleById.set(promptAdmission.messageId, "user"); | 1400 | context.messageRoleById.set(promptAdmission.messageId, "user"); |
| 1390 | context.textPartsByMessageId.delete(promptAdmission.messageId); | 1401 | context.textPartsByMessageId.delete(promptAdmission.messageId); |
| 1391 | } | 1402 | } |
| 1392 | } | 1403 | } |
| 1393 | 1404 | ||
| 1405 | // Native command responses wait for generation. Recover their receipt | ||
| 1406 | // first, then let sendTurn acknowledge admission before reconciling idle. | ||
| 1407 | if (promptAdmission.requiresMessageReceipt && !promptAdmission.accepted) { | ||
| 1408 | if (!promptAdmission.messageObserved) { | ||
| 1409 | yield* Effect.sleep(`${Math.min(250 * 2 ** retryCount, 2_000)} millis`); | ||
| 1410 | continue; | ||
| 1411 | } | ||
| 1412 | retryCount = 0; | ||
| 1413 | } | ||
| 1414 | yield* Deferred.await(promptAdmission.acceptance); | ||
| 1415 | |||
| 1394 | const statusResponse = yield* runOpenCodeSdk("session.status", (signal) => | 1416 | const statusResponse = yield* runOpenCodeSdk("session.status", (signal) => |
| 1395 | context.client.session.status(undefined, { signal }), | 1417 | context.client.session.status(undefined, { signal }), |
| 1396 | ).pipe(Effect.timeout("1 second"), Effect.option); | 1418 | ).pipe(Effect.timeout("1 second"), Effect.option); |
| ... | @@ -2312,6 +2334,7 @@ export function makeOpenCodeAdapter( | ... | @@ -2312,6 +2334,7 @@ export function makeOpenCodeAdapter( |
| 2312 | promptAdmission?.messageId === event.properties.info.id | 2334 | promptAdmission?.messageId === event.properties.info.id |
| 2313 | ) { | 2335 | ) { |
| 2314 | promptAdmission.messageObserved = true; | 2336 | promptAdmission.messageObserved = true; |
| 2337 | yield* Deferred.succeed(promptAdmission.messageReceipt, undefined); | ||
| 2315 | if (promptAdmission.accepted) { | 2338 | if (promptAdmission.accepted) { |
| 2316 | const idle = promptAdmission.idleDuringAdmission; | 2339 | const idle = promptAdmission.idleDuringAdmission; |
| 2317 | context.awaitingBusyAfterInterruption = false; | 2340 | context.awaitingBusyAfterInterruption = false; |
| ... | @@ -3006,6 +3029,7 @@ export function makeOpenCodeAdapter( | ... | @@ -3006,6 +3029,7 @@ export function makeOpenCodeAdapter( |
| 3006 | pendingRequestRecovery: undefined, | 3029 | pendingRequestRecovery: undefined, |
| 3007 | promptGeneration: 0, | 3030 | promptGeneration: 0, |
| 3008 | promptAdmission: undefined, | 3031 | promptAdmission: undefined, |
| 3032 | commandFibers: new Set(), | ||
| 3009 | promptSemaphore: Semaphore.makeUnsafe(1), | 3033 | promptSemaphore: Semaphore.makeUnsafe(1), |
| 3010 | firstConnection: Deferred.makeUnsafe<void, ProviderAdapterRequestError>(), | 3034 | firstConnection: Deferred.makeUnsafe<void, ProviderAdapterRequestError>(), |
| 3011 | stopped: yield* Ref.make(false), | 3035 | stopped: yield* Ref.make(false), |
| ... | @@ -3093,6 +3117,13 @@ export function makeOpenCodeAdapter( | ... | @@ -3093,6 +3117,13 @@ export function makeOpenCodeAdapter( |
| 3093 | } | 3117 | } |
| 3094 | 3118 | ||
| 3095 | const text = input.input?.trim(); | 3119 | const text = input.input?.trim(); |
| 3120 | const commandMatch = text?.match(/^\/([^\s/]+)(?:\s+([\s\S]*))?$/); | ||
| 3121 | const nativeCommand = commandMatch | ||
| 3122 | ? (yield* loadOpenCodeCommands(context.client).pipe( | ||
| 3123 | Effect.timeout("10 seconds"), | ||
| 3124 | Effect.orElseSucceed(() => []), | ||
| 3125 | )).find((command) => command.name === commandMatch[1]) | ||
| 3126 | : undefined; | ||
| 3096 | // OpenCode ingests images, text, and PDFs natively; formats its model | 3127 | // OpenCode ingests images, text, and PDFs natively; formats its model |
| 3097 | // paths reject ride only as the prompt's file path line. | 3128 | // paths reject ride only as the prompt's file path line. |
| 3098 | const fileParts = toOpenCodeFileParts({ | 3129 | const fileParts = toOpenCodeFileParts({ |
| ... | @@ -3150,6 +3181,7 @@ export function makeOpenCodeAdapter( | ... | @@ -3150,6 +3181,7 @@ export function makeOpenCodeAdapter( |
| 3150 | generation: promptGeneration, | 3181 | generation: promptGeneration, |
| 3151 | turnId, | 3182 | turnId, |
| 3152 | messageId, | 3183 | messageId, |
| 3184 | requiresMessageReceipt: nativeCommand !== undefined, | ||
| 3153 | priorAwaitingBusy, | 3185 | priorAwaitingBusy, |
| 3154 | priorIdle: priorIdleCandidate, | 3186 | priorIdle: priorIdleCandidate, |
| 3155 | idleDuringAdmission: undefined, | 3187 | idleDuringAdmission: undefined, |
| ... | @@ -3160,6 +3192,7 @@ export function makeOpenCodeAdapter( | ... | @@ -3160,6 +3192,7 @@ export function makeOpenCodeAdapter( |
| 3160 | accepted: false, | 3192 | accepted: false, |
| 3161 | cancelled: false, | 3193 | cancelled: false, |
| 3162 | acceptance: Deferred.makeUnsafe<void>(), | 3194 | acceptance: Deferred.makeUnsafe<void>(), |
| 3195 | messageReceipt: Deferred.makeUnsafe<void>(), | ||
| 3163 | submissionSettled: Deferred.makeUnsafe<void>(), | 3196 | submissionSettled: Deferred.makeUnsafe<void>(), |
| 3164 | recoveryRaw: undefined, | 3197 | recoveryRaw: undefined, |
| 3165 | }; | 3198 | }; |
| ... | @@ -3211,25 +3244,52 @@ export function makeOpenCodeAdapter( | ... | @@ -3211,25 +3244,52 @@ export function makeOpenCodeAdapter( |
| 3211 | } | 3244 | } |
| 3212 | 3245 | ||
| 3213 | let promptTimedOut = false; | 3246 | let promptTimedOut = false; |
| 3214 | const promptEffect = runOpenCodeSdk("session.promptAsync", (signal) => | 3247 | const submissionMethod = nativeCommand ? "session.command" : "session.promptAsync"; |
| 3215 | context.client.session.promptAsync( | 3248 | // Native commands expand provider-owned templates. Their API does not |
| 3216 | { | 3249 | // accept the per-turn system addendum supported by ordinary prompts. |
| 3217 | sessionID: context.openCodeSessionId, | 3250 | const submission = nativeCommand |
| 3218 | messageID: messageId, | 3251 | ? Effect.raceFirst( |
| 3219 | model: parsedModel, | 3252 | runOpenCodeSdk("session.command", (signal) => |
| 3220 | ...(context.activeAgent ? { agent: context.activeAgent } : {}), | 3253 | context.client.session.command( |
| 3221 | ...(context.activeVariant ? { variant: context.activeVariant } : {}), | 3254 | { |
| 3222 | // OpenCode appends this after its own agent/provider prompts. | 3255 | sessionID: context.openCodeSessionId, |
| 3223 | system: buildRuntimeInstructions({ | 3256 | messageID: messageId, |
| 3224 | harness: "OpenCode", | 3257 | command: nativeCommand.name, |
| 3225 | model: `${parsedModel.providerID}/${parsedModel.modelID}`, | 3258 | arguments: commandMatch?.[2] ?? "", |
| 3226 | }), | 3259 | model: `${parsedModel.providerID}/${parsedModel.modelID}`, |
| 3227 | parts: [...(text ? [{ type: "text" as const, text }] : []), ...fileParts], | 3260 | ...(context.activeAgent ? { agent: context.activeAgent } : {}), |
| 3228 | }, | 3261 | ...(context.activeVariant ? { variant: context.activeVariant } : {}), |
| 3229 | { signal }, | 3262 | parts: fileParts, |
| 3230 | ), | 3263 | }, |
| 3231 | ).pipe( | 3264 | { signal }, |
| 3232 | Effect.timeout("10 seconds"), | 3265 | ), |
| 3266 | ).pipe(Effect.asVoid), | ||
| 3267 | // A command response waits for generation. Only bound admission; | ||
| 3268 | // the user-message receipt proves OpenCode accepted the command. | ||
| 3269 | Deferred.await(promptAdmission.messageReceipt).pipe( | ||
| 3270 | Effect.timeout("10 seconds"), | ||
| 3271 | Effect.andThen(Effect.never), | ||
| 3272 | ), | ||
| 3273 | ) | ||
| 3274 | : runOpenCodeSdk("session.promptAsync", (signal) => | ||
| 3275 | context.client.session.promptAsync( | ||
| 3276 | { | ||
| 3277 | sessionID: context.openCodeSessionId, | ||
| 3278 | messageID: messageId, | ||
| 3279 | model: parsedModel, | ||
| 3280 | ...(context.activeAgent ? { agent: context.activeAgent } : {}), | ||
| 3281 | ...(context.activeVariant ? { variant: context.activeVariant } : {}), | ||
| 3282 | // OpenCode appends this after its own agent/provider prompts. | ||
| 3283 | system: buildRuntimeInstructions({ | ||
| 3284 | harness: "OpenCode", | ||
| 3285 | model: `${parsedModel.providerID}/${parsedModel.modelID}`, | ||
| 3286 | }), | ||
| 3287 | parts: [...(text ? [{ type: "text" as const, text }] : []), ...fileParts], | ||
| 3288 | }, | ||
| 3289 | { signal }, | ||
| 3290 | ), | ||
| 3291 | ).pipe(Effect.timeout("10 seconds"), Effect.asVoid); | ||
| 3292 | const promptEffect = submission.pipe( | ||
| 3233 | Effect.catchTags({ | 3293 | Effect.catchTags({ |
| 3234 | OpenCodeRuntimeError: (cause) => Effect.fail(toRequestError(cause)), | 3294 | OpenCodeRuntimeError: (cause) => Effect.fail(toRequestError(cause)), |
| 3235 | TimeoutError: (cause) => { | 3295 | TimeoutError: (cause) => { |
| ... | @@ -3237,15 +3297,47 @@ export function makeOpenCodeAdapter( | ... | @@ -3237,15 +3297,47 @@ export function makeOpenCodeAdapter( |
| 3237 | return Effect.fail( | 3297 | return Effect.fail( |
| 3238 | new ProviderAdapterRequestError({ | 3298 | new ProviderAdapterRequestError({ |
| 3239 | provider: PROVIDER, | 3299 | provider: PROVIDER, |
| 3240 | method: "session.promptAsync", | 3300 | method: submissionMethod, |
| 3241 | detail: "OpenCode prompt submission did not complete within 10 seconds.", | 3301 | detail: "OpenCode prompt submission did not complete within 10 seconds.", |
| 3242 | cause, | 3302 | cause, |
| 3243 | }), | 3303 | }), |
| 3244 | ); | 3304 | ); |
| 3245 | }, | 3305 | }, |
| 3246 | }), | 3306 | }), |
| 3247 | Effect.tapError((requestError) => | 3307 | Effect.tapError(() => { |
| 3248 | context.promptAdmission !== promptAdmission || context.activeTurnId !== turnId | 3308 | promptAdmission.requiresMessageReceipt = false; |
| 3309 | return nativeCommand && promptAdmission.recoveryFiber | ||
| 3310 | ? Fiber.interrupt(promptAdmission.recoveryFiber) | ||
| 3311 | : Effect.void; | ||
| 3312 | }), | ||
| 3313 | Effect.tapError((requestError) => { | ||
| 3314 | if ( | ||
| 3315 | nativeCommand && | ||
| 3316 | (promptAdmission.cancelled || context.cancellation?.turnId === turnId) | ||
| 3317 | ) { | ||
| 3318 | return Effect.void; | ||
| 3319 | } | ||
| 3320 | if ( | ||
| 3321 | nativeCommand && | ||
| 3322 | promptAdmission.accepted && | ||
| 3323 | context.activeTurnId === turnId && | ||
| 3324 | (steeringTurnId !== undefined || | ||
| 3325 | context.promptGeneration !== promptAdmission.generation) | ||
| 3326 | ) { | ||
| 3327 | return Effect.gen(function* () { | ||
| 3328 | yield* emit({ | ||
| 3329 | ...(yield* buildEventBase({ threadId: input.threadId, turnId })), | ||
| 3330 | type: "runtime.warning", | ||
| 3331 | payload: { | ||
| 3332 | message: `OpenCode /${nativeCommand.name} failed after it was accepted.`, | ||
| 3333 | detail: requestError.detail, | ||
| 3334 | }, | ||
| 3335 | }); | ||
| 3336 | }); | ||
| 3337 | } | ||
| 3338 | return (nativeCommand | ||
| 3339 | ? context.promptGeneration !== promptAdmission.generation | ||
| 3340 | : context.promptAdmission !== promptAdmission) || context.activeTurnId !== turnId | ||
| 3249 | ? Effect.void | 3341 | ? Effect.void |
| 3250 | : Effect.gen(function* () { | 3342 | : Effect.gen(function* () { |
| 3251 | if (!promptTimedOut) { | 3343 | if (!promptTimedOut) { |
| ... | @@ -3334,8 +3426,8 @@ export function makeOpenCodeAdapter( | ... | @@ -3334,8 +3426,8 @@ export function makeOpenCodeAdapter( |
| 3334 | tokenUsage, | 3426 | tokenUsage, |
| 3335 | }, | 3427 | }, |
| 3336 | }); | 3428 | }); |
| 3337 | }), | 3429 | }); |
| 3338 | ), | 3430 | }), |
| 3339 | Effect.onExit((exit) => | 3431 | Effect.onExit((exit) => |
| 3340 | Effect.gen(function* () { | 3432 | Effect.gen(function* () { |
| 3341 | yield* Deferred.succeed(promptAdmission.submissionSettled, undefined).pipe( | 3433 | yield* Deferred.succeed(promptAdmission.submissionSettled, undefined).pipe( |
| ... | @@ -3352,8 +3444,20 @@ export function makeOpenCodeAdapter( | ... | @@ -3352,8 +3444,20 @@ export function makeOpenCodeAdapter( |
| 3352 | ); | 3444 | ); |
| 3353 | const promptFiber = yield* promptEffect.pipe(Effect.forkIn(context.sessionScope)); | 3445 | const promptFiber = yield* promptEffect.pipe(Effect.forkIn(context.sessionScope)); |
| 3354 | promptAdmission.promptFiber = promptFiber; | 3446 | promptAdmission.promptFiber = promptFiber; |
| 3355 | const promptExit = yield* Effect.exit(Fiber.join(promptFiber)); | 3447 | if (nativeCommand) { |
| 3356 | delete promptAdmission.promptFiber; | 3448 | context.commandFibers.add(promptFiber); |
| 3449 | promptFiber.addObserver(() => context.commandFibers.delete(promptFiber)); | ||
| 3450 | yield* schedulePromptAdmissionRecovery(context, undefined); | ||
| 3451 | } | ||
| 3452 | const promptExit = yield* Effect.exit( | ||
| 3453 | nativeCommand | ||
| 3454 | ? Effect.raceFirst( | ||
| 3455 | Fiber.join(promptFiber), | ||
| 3456 | Deferred.await(promptAdmission.messageReceipt), | ||
| 3457 | ) | ||
| 3458 | : Fiber.join(promptFiber), | ||
| 3459 | ); | ||
| 3460 | if (!nativeCommand) delete promptAdmission.promptFiber; | ||
| 3357 | 3461 | ||
| 3358 | const intentionallyCancelled = | 3462 | const intentionallyCancelled = |
| 3359 | promptAdmission.cancelled || | 3463 | promptAdmission.cancelled || |
| ... | @@ -3527,6 +3631,8 @@ export function makeOpenCodeAdapter( | ... | @@ -3527,6 +3631,8 @@ export function makeOpenCodeAdapter( |
| 3527 | yield* Deferred.await(promptAdmission.submissionSettled); | 3631 | yield* Deferred.await(promptAdmission.submissionSettled); |
| 3528 | } | 3632 | } |
| 3529 | 3633 | ||
| 3634 | yield* Effect.forEach([...context.commandFibers], Fiber.interrupt, { discard: true }); | ||
| 3635 | |||
| 3530 | const parentAbortOutcome = yield* Effect.raceFirst( | 3636 | const parentAbortOutcome = yield* Effect.raceFirst( |
| 3531 | runOpenCodeSdk("session.abort", (signal) => | 3637 | runOpenCodeSdk("session.abort", (signal) => |
| 3532 | context.client.session.abort({ sessionID: context.openCodeSessionId }, { signal }), | 3638 | context.client.session.abort({ sessionID: context.openCodeSessionId }, { signal }), |
apps/server/src/provider/Layers/OpenCodeProvider.test.ts+20-1| ... | @@ -18,7 +18,10 @@ import { | ... | @@ -18,7 +18,10 @@ import { |
| 18 | type OpenCodeRuntimeShape, | 18 | type OpenCodeRuntimeShape, |
| 19 | } from "../opencodeRuntime.ts"; | 19 | } from "../opencodeRuntime.ts"; |
| 20 | import * as OpenCodeServerOwner from "../OpenCodeServerOwner.ts"; | 20 | import * as OpenCodeServerOwner from "../OpenCodeServerOwner.ts"; |
| 21 | import { checkOpenCodeProviderStatus } from "./OpenCodeProvider.ts"; | 21 | import { |
| 22 | checkOpenCodeProviderStatus, | ||
| 23 | openCodeCommandsToServerProviderSlashCommands, | ||
| 24 | } from "./OpenCodeProvider.ts"; | ||
| 22 | import type { OpenCodeInventory } from "../opencodeRuntime.ts"; | 25 | import type { OpenCodeInventory } from "../opencodeRuntime.ts"; |
| 23 | const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings); | 26 | const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings); |
| 24 | 27 | ||
| ... | @@ -163,6 +166,22 @@ beforeEach(() => { | ... | @@ -163,6 +166,22 @@ beforeEach(() => { |
| 163 | runtimeMock.reset(); | 166 | runtimeMock.reset(); |
| 164 | }); | 167 | }); |
| 165 | 168 | ||
| 169 | it("keeps native and MCP commands while preserving compaction and separate skills", () => { | ||
| 170 | NodeAssert.deepEqual( | ||
| 171 | openCodeCommandsToServerProviderSlashCommands([ | ||
| 172 | { name: "review", description: "Review changes", source: "command", hints: ["$ARGUMENTS"] }, | ||
| 173 | { name: "review", source: "command", hints: [] }, | ||
| 174 | { name: "compact", source: "command", hints: [] }, | ||
| 175 | { name: "skill", source: "skill", hints: [] }, | ||
| 176 | { name: "mcp:search", source: "mcp", hints: ["query"] }, | ||
| 177 | ]).slice(1), | ||
| 178 | [ | ||
| 179 | { name: "review", description: "Review changes", input: { hint: "$ARGUMENTS" } }, | ||
| 180 | { name: "mcp:search", input: { hint: "query" } }, | ||
| 181 | ], | ||
| 182 | ); | ||
| 183 | }); | ||
| 184 | |||
| 166 | const testLayer = Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble).pipe( | 185 | const testLayer = Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble).pipe( |
| 167 | Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), | 186 | Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), |
| 168 | Layer.provideMerge(NodeServices.layer), | 187 | Layer.provideMerge(NodeServices.layer), |
apps/server/src/provider/Layers/OpenCodeProvider.ts+24-1| ... | @@ -3,6 +3,7 @@ import { | ... | @@ -3,6 +3,7 @@ import { |
| 3 | type OpenCodeSettings, | 3 | type OpenCodeSettings, |
| 4 | type ServerProviderModel, | 4 | type ServerProviderModel, |
| 5 | type ServerProviderSkill, | 5 | type ServerProviderSkill, |
| 6 | type ServerProviderSlashCommand, | ||
| 6 | } from "@t3tools/contracts"; | 7 | } from "@t3tools/contracts"; |
| 7 | import * as Cause from "effect/Cause"; | 8 | import * as Cause from "effect/Cause"; |
| 8 | import * as Data from "effect/Data"; | 9 | import * as Data from "effect/Data"; |
| ... | @@ -315,6 +316,26 @@ export function openCodeSkillsToServerProviderSkills( | ... | @@ -315,6 +316,26 @@ export function openCodeSkillsToServerProviderSkills( |
| 315 | return skills.toSorted((left, right) => left.name.localeCompare(right.name)); | 316 | return skills.toSorted((left, right) => left.name.localeCompare(right.name)); |
| 316 | } | 317 | } |
| 317 | 318 | ||
| 319 | export function openCodeCommandsToServerProviderSlashCommands( | ||
| 320 | input: OpenCodeInventory["commands"], | ||
| 321 | ): ReadonlyArray<ServerProviderSlashCommand> { | ||
| 322 | const commands: ServerProviderSlashCommand[] = [COMPACT_SLASH_COMMAND]; | ||
| 323 | const names = new Set([COMPACT_SLASH_COMMAND.name]); | ||
| 324 | for (const command of input ?? []) { | ||
| 325 | const name = trimOptional(command.name); | ||
| 326 | if (!name || names.has(name) || command.source === "skill") continue; | ||
| 327 | names.add(name); | ||
| 328 | const description = trimOptional(command.description); | ||
| 329 | const hint = trimOptional(command.hints.join(" ")); | ||
| 330 | commands.push({ | ||
| 331 | name, | ||
| 332 | ...(description ? { description } : {}), | ||
| 333 | ...(hint ? { input: { hint } } : {}), | ||
| 334 | }); | ||
| 335 | } | ||
| 336 | return commands; | ||
| 337 | } | ||
| 338 | |||
| 318 | export const makePendingOpenCodeProvider = ( | 339 | export const makePendingOpenCodeProvider = ( |
| 319 | openCodeSettings: OpenCodeSettings, | 340 | openCodeSettings: OpenCodeSettings, |
| 320 | ): Effect.Effect<ServerProviderDraft> => | 341 | ): Effect.Effect<ServerProviderDraft> => |
| ... | @@ -526,7 +547,9 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu | ... | @@ -526,7 +547,9 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu |
| 526 | checkedAt, | 547 | checkedAt, |
| 527 | models, | 548 | models, |
| 528 | skills, | 549 | skills, |
| 529 | slashCommands: [COMPACT_SLASH_COMMAND], | 550 | slashCommands: openCodeCommandsToServerProviderSlashCommands( |
| 551 | inventoryExit.value.inventory.commands, | ||
| 552 | ), | ||
| 530 | probe: { | 553 | probe: { |
| 531 | installed: true, | 554 | installed: true, |
| 532 | version, | 555 | version, |
apps/server/src/provider/acp/AcpSessionRuntime.ts+20-2| ... | @@ -358,6 +358,7 @@ export const make = ( | ... | @@ -358,6 +358,7 @@ export const make = ( |
| 358 | const promptSerializationSemaphore = yield* Semaphore.make(1); | 358 | const promptSerializationSemaphore = yield* Semaphore.make(1); |
| 359 | const promptDispatchSemaphore = yield* Semaphore.make(1); | 359 | const promptDispatchSemaphore = yield* Semaphore.make(1); |
| 360 | const activePromptRef = yield* Ref.make<Option.Option<AcpActivePrompt>>(Option.none()); | 360 | const activePromptRef = yield* Ref.make<Option.Option<AcpActivePrompt>>(Option.none()); |
| 361 | const assistantUpdatesOpenRef = yield* Ref.make(true); | ||
| 361 | const sessionLoadGateRef = yield* Ref.make<Option.Option<SessionLoadGate>>(Option.none()); | 362 | const sessionLoadGateRef = yield* Ref.make<Option.Option<SessionLoadGate>>(Option.none()); |
| 362 | 363 | ||
| 363 | const ensureConnected = Effect.gen(function* () { | 364 | const ensureConnected = Effect.gen(function* () { |
| ... | @@ -547,6 +548,13 @@ export const make = ( | ... | @@ -547,6 +548,13 @@ export const make = ( |
| 547 | ) { | 548 | ) { |
| 548 | return; | 549 | return; |
| 549 | } | 550 | } |
| 551 | if ( | ||
| 552 | !(yield* Ref.get(assistantUpdatesOpenRef)) && | ||
| 553 | (notification.update.sessionUpdate === "agent_message_chunk" || | ||
| 554 | notification.update.sessionUpdate === "agent_thought_chunk") | ||
| 555 | ) { | ||
| 556 | return; | ||
| 557 | } | ||
| 550 | yield* processSessionUpdate(notification); | 558 | yield* processSessionUpdate(notification); |
| 551 | }), | 559 | }), |
| 552 | ), | 560 | ), |
| ... | @@ -905,7 +913,16 @@ export const make = ( | ... | @@ -905,7 +913,16 @@ export const make = ( |
| 905 | return; | 913 | return; |
| 906 | } | 914 | } |
| 907 | const acknowledge = yield* Deferred.make<void>(); | 915 | const acknowledge = yield* Deferred.make<void>(); |
| 908 | yield* Queue.offer(eventQueue, { _tag: "EventStreamBarrier", acknowledge }); | 916 | yield* notificationSemaphore.withPermit( |
| 917 | Effect.gen(function* () { | ||
| 918 | // Keep a provider's final flushed chunks together until the adapter settles the turn. | ||
| 919 | if (Option.isNone(yield* Ref.get(activePromptRef))) { | ||
| 920 | yield* Ref.set(assistantUpdatesOpenRef, false); | ||
| 921 | yield* closeActiveAssistantSegment({ queue: eventQueue, assistantSegmentRef }); | ||
| 922 | } | ||
| 923 | yield* Queue.offer(eventQueue, { _tag: "EventStreamBarrier", acknowledge }); | ||
| 924 | }), | ||
| 925 | ); | ||
| 909 | yield* Effect.raceFirst(Deferred.await(acknowledge), Deferred.await(runtimeClosed)); | 926 | yield* Effect.raceFirst(Deferred.await(acknowledge), Deferred.await(runtimeClosed)); |
| 910 | }); | 927 | }); |
| 911 | 928 | ||
| ... | @@ -984,6 +1001,7 @@ export const make = ( | ... | @@ -984,6 +1001,7 @@ export const make = ( |
| 984 | Effect.gen(function* () { | 1001 | Effect.gen(function* () { |
| 985 | const started = yield* getStartedState; | 1002 | const started = yield* getStartedState; |
| 986 | yield* closeActiveAssistantSegment({ queue: eventQueue, assistantSegmentRef }); | 1003 | yield* closeActiveAssistantSegment({ queue: eventQueue, assistantSegmentRef }); |
| 1004 | yield* Ref.set(assistantUpdatesOpenRef, true); | ||
| 987 | const requestPayload = { | 1005 | const requestPayload = { |
| 988 | sessionId: started.sessionId, | 1006 | sessionId: started.sessionId, |
| 989 | ...payload, | 1007 | ...payload, |
| ... | @@ -1000,7 +1018,7 @@ export const make = ( | ... | @@ -1000,7 +1018,7 @@ export const make = ( |
| 1000 | yield* Deferred.succeed(promptOptions.dispatched, undefined); | 1018 | yield* Deferred.succeed(promptOptions.dispatched, undefined); |
| 1001 | } | 1019 | } |
| 1002 | return active; | 1020 | return active; |
| 1003 | }), | 1021 | }).pipe(notificationSemaphore.withPermit), |
| 1004 | ), | 1022 | ), |
| 1005 | (activePrompt) => | 1023 | (activePrompt) => |
| 1006 | Fiber.join(activePrompt.fiber).pipe( | 1024 | Fiber.join(activePrompt.fiber).pipe( |
apps/server/src/provider/opencodeRuntime.inventory.test.ts+54-1| ... | @@ -47,17 +47,70 @@ it.layer(testLayer)("OpenCodeRuntime inventory", (it) => { | ... | @@ -47,17 +47,70 @@ it.layer(testLayer)("OpenCodeRuntime inventory", (it) => { |
| 47 | }); | 47 | }); |
| 48 | 48 | ||
| 49 | const inventoryFiber = yield* runtime.loadOpenCodeInventory(client).pipe(Effect.forkChild); | 49 | const inventoryFiber = yield* runtime.loadOpenCodeInventory(client).pipe(Effect.forkChild); |
| 50 | yield* Queue.takeN(started, 3); | 50 | yield* Queue.takeN(started, 4); |
| 51 | yield* Fiber.interrupt(inventoryFiber); | 51 | yield* Fiber.interrupt(inventoryFiber); |
| 52 | 52 | ||
| 53 | NodeAssert.deepEqual((yield* Queue.takeAll(aborted)).toSorted(), [ | 53 | NodeAssert.deepEqual((yield* Queue.takeAll(aborted)).toSorted(), [ |
| 54 | "/agent", | 54 | "/agent", |
| 55 | "/command", | ||
| 55 | "/provider", | 56 | "/provider", |
| 56 | "/skill", | 57 | "/skill", |
| 57 | ]); | 58 | ]); |
| 58 | }), | 59 | }), |
| 59 | ); | 60 | ); |
| 60 | 61 | ||
| 62 | it.effect("discovers directory-scoped commands without retaining prompt templates", () => | ||
| 63 | Effect.gen(function* () { | ||
| 64 | const runtime = yield* OpenCodeRuntime; | ||
| 65 | const requests: Request[] = []; | ||
| 66 | const client = createOpencodeClient({ | ||
| 67 | baseUrl: "http://opencode.test", | ||
| 68 | directory: "/workspace/project", | ||
| 69 | fetch: Object.assign( | ||
| 70 | async (input: string | Request | URL) => { | ||
| 71 | const request = input instanceof Request ? input : new Request(input.toString()); | ||
| 72 | requests.push(request); | ||
| 73 | const route = new URL(request.url).pathname; | ||
| 74 | return Response.json( | ||
| 75 | route === "/provider" | ||
| 76 | ? { connected: ["openai"], all: [], default: {} } | ||
| 77 | : route === "/command" | ||
| 78 | ? [ | ||
| 79 | { | ||
| 80 | name: "review", | ||
| 81 | description: "Review changes", | ||
| 82 | source: "command", | ||
| 83 | hints: ["$ARGUMENTS"], | ||
| 84 | template: "private native template", | ||
| 85 | }, | ||
| 86 | ] | ||
| 87 | : [], | ||
| 88 | ); | ||
| 89 | }, | ||
| 90 | { preconnect: () => undefined }, | ||
| 91 | ), | ||
| 92 | }); | ||
| 93 | const inventory = yield* runtime.loadOpenCodeInventory(client); | ||
| 94 | NodeAssert.deepEqual(inventory.commands, [ | ||
| 95 | { | ||
| 96 | name: "review", | ||
| 97 | description: "Review changes", | ||
| 98 | source: "command", | ||
| 99 | hints: ["$ARGUMENTS"], | ||
| 100 | }, | ||
| 101 | ]); | ||
| 102 | const commandRequest = requests.find( | ||
| 103 | (request) => new URL(request.url).pathname === "/command", | ||
| 104 | ); | ||
| 105 | NodeAssert.ok(commandRequest); | ||
| 106 | NodeAssert.equal( | ||
| 107 | new URL(commandRequest.url).searchParams.get("directory") ?? | ||
| 108 | decodeURIComponent(commandRequest.headers.get("x-opencode-directory") ?? ""), | ||
| 109 | "/workspace/project", | ||
| 110 | ); | ||
| 111 | }), | ||
| 112 | ); | ||
| 113 | |||
| 61 | it.effect("keeps provider inventory when agent discovery fails", () => | 114 | it.effect("keeps provider inventory when agent discovery fails", () => |
| 62 | Effect.gen(function* () { | 115 | Effect.gen(function* () { |
| 63 | const runtime = yield* OpenCodeRuntime; | 116 | const runtime = yield* OpenCodeRuntime; |
apps/server/src/provider/opencodeRuntime.ts+35-3| ... | @@ -4,6 +4,7 @@ import type { ChatAttachment, ProviderApprovalDecision, RuntimeMode } from "@t3t | ... | @@ -4,6 +4,7 @@ import type { ChatAttachment, ProviderApprovalDecision, RuntimeMode } from "@t3t |
| 4 | import { | 4 | import { |
| 5 | createOpencodeClient, | 5 | createOpencodeClient, |
| 6 | type Agent, | 6 | type Agent, |
| 7 | type Command, | ||
| 7 | type FilePartInput, | 8 | type FilePartInput, |
| 8 | type Model, | 9 | type Model, |
| 9 | type OpencodeClient, | 10 | type OpencodeClient, |
| ... | @@ -187,8 +188,24 @@ export interface OpenCodeInventory { | ... | @@ -187,8 +188,24 @@ export interface OpenCodeInventory { |
| 187 | readonly providerList: ProviderListResponse; | 188 | readonly providerList: ProviderListResponse; |
| 188 | readonly agents: ReadonlyArray<Agent>; | 189 | readonly agents: ReadonlyArray<Agent>; |
| 189 | readonly skills: ReadonlyArray<OpenCodeSkill>; | 190 | readonly skills: ReadonlyArray<OpenCodeSkill>; |
| 191 | readonly commands?: ReadonlyArray<OpenCodeSlashCommand>; | ||
| 190 | } | 192 | } |
| 191 | 193 | ||
| 194 | export type OpenCodeSlashCommand = Pick<Command, "name" | "description" | "source" | "hints">; | ||
| 195 | |||
| 196 | /** Command templates stay in OpenCode, which expands arguments and runs MCP prompts. */ | ||
| 197 | export const loadOpenCodeCommands = (client: OpencodeClient) => | ||
| 198 | runOpenCodeSdk("command.list", (signal) => client.command.list(undefined, { signal })).pipe( | ||
| 199 | Effect.map((result): ReadonlyArray<OpenCodeSlashCommand> => | ||
| 200 | (result.data ?? []).map(({ name, description, source, hints }) => ({ | ||
| 201 | name, | ||
| 202 | ...(description === undefined ? {} : { description }), | ||
| 203 | ...(source === undefined ? {} : { source }), | ||
| 204 | hints, | ||
| 205 | })), | ||
| 206 | ), | ||
| 207 | ); | ||
| 208 | |||
| 192 | export interface ParsedOpenCodeModelSlug { | 209 | export interface ParsedOpenCodeModelSlug { |
| 193 | readonly providerID: string; | 210 | readonly providerID: string; |
| 194 | readonly modelID: string; | 211 | readonly modelID: string; |
| ... | @@ -923,9 +940,24 @@ const makeOpenCodeRuntime = Effect.gen(function* () { | ... | @@ -923,9 +940,24 @@ const makeOpenCodeRuntime = Effect.gen(function* () { |
| 923 | loadOpenCodeSkills(client).pipe(Effect.orElseSucceed((): ReadonlyArray<OpenCodeSkill> => [])); | 940 | loadOpenCodeSkills(client).pipe(Effect.orElseSucceed((): ReadonlyArray<OpenCodeSkill> => [])); |
| 924 | 941 | ||
| 925 | const loadOpenCodeInventory: OpenCodeRuntimeShape["loadOpenCodeInventory"] = (client) => | 942 | const loadOpenCodeInventory: OpenCodeRuntimeShape["loadOpenCodeInventory"] = (client) => |
| 926 | Effect.all([loadProviders(client), loadAgents(client), loadSkills(client)], { | 943 | Effect.all( |
| 927 | concurrency: "unbounded", | 944 | [ |
| 928 | }).pipe(Effect.map(([providerList, agents, skills]) => ({ providerList, agents, skills }))); | 945 | loadProviders(client), |
| 946 | loadAgents(client), | ||
| 947 | loadSkills(client), | ||
| 948 | loadOpenCodeCommands(client).pipe(Effect.orElseSucceed(() => [])), | ||
| 949 | ], | ||
| 950 | { | ||
| 951 | concurrency: "unbounded", | ||
| 952 | }, | ||
| 953 | ).pipe( | ||
| 954 | Effect.map(([providerList, agents, skills, commands]) => ({ | ||
| 955 | providerList, | ||
| 956 | agents, | ||
| 957 | skills, | ||
| 958 | commands, | ||
| 959 | })), | ||
| 960 | ); | ||
| 929 | 961 | ||
| 930 | const loadInventoryFromCli: OpenCodeRuntimeShape["loadInventoryFromCli"] = (input) => | 962 | const loadInventoryFromCli: OpenCodeRuntimeShape["loadInventoryFromCli"] = (input) => |
| 931 | Effect.gen(function* () { | 963 | Effect.gen(function* () { |