authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-16 13:58:07-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-16 13:58:07-03:00
log0b83045d00d88d0a0b0866fe4942a50a41a08471
tree1a0e76a95820fcdd52d7d397d23bc90eb4d00526
parente8ca3a82806dff84438d5aba66b693468f7ca426
signature Signed by PGP key B5690EEEBB952194

feat(providers): expose native slash commands across clients (#11519)


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 {
29import {29import {
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";
35import { useCallback, useEffect, useMemo, useRef, useState } from "react";37import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3638
...@@ -329,6 +331,7 @@ export function useComposerCommandMenu({...@@ -329,6 +331,7 @@ export function useComposerCommandMenu({
329331
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 });
341352
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 });
790790
...@@ -826,13 +826,15 @@ const program = Effect.gen(function* () {...@@ -826,13 +826,15 @@ const program = Effect.gen(function* () {
826 });826 });
827 }827 }
828828
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 }
836838
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";
35import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts";36import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts";
36import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";37import { 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";
56import { probeCursorSkills } from "./CursorSkills.ts";57import { discoverCursorSkills, probeCursorSkills } from "./CursorSkills.ts";
57const decodeCursorSettings = Schema.decodeSync(CursorSettings);58const decodeCursorSettings = Schema.decodeSync(CursorSettings);
5859
59const DRIVER_KIND = ProviderDriverKind.make("cursor");60const 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 );
132133
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);
139135
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 );
152148
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 );
190188
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.enabled212 !effectiveConfig.enabled
201 ? snapshot.getSnapshot213 ? 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";
35import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts";36import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts";
36import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";37import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
37import { OpenCodeRuntime } from "../opencodeRuntime.ts";38import { OpenCodeRuntime, loadOpenCodeCommands } from "../opencodeRuntime.ts";
38import * as OpenCodeServerOwner from "../OpenCodeServerOwner.ts";39import * as OpenCodeServerOwner from "../OpenCodeServerOwner.ts";
39import {40import {
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 `$` picker165 // empty skill list and poisons the workspace snapshot the `$` picker
165 // reads. The SDK `app.skills` endpoint honors the per-request directory166 // 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 > 0180 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.getSnapshot259 ? 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 );
304304
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);
351352
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}
122126
123interface PendingApproval {127interface 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 }
10591068
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.acp1071 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 @@
1import * as NodeOS from "node:os";1import * as NodeOS from "node:os";
22
3import * as NodeServices from "@effect/platform-node/NodeServices";3import * as NodeServices from "@effect/platform-node/NodeServices";
4import { it as effectIt } from "@effect/vitest";
4import type * as Crypto from "effect/Crypto";5import type * as Crypto from "effect/Crypto";
5import * as Effect from "effect/Effect";6import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";7import * as FileSystem from "effect/FileSystem";
7import * as Path from "effect/Path";8import * as Path from "effect/Path";
9import * as Stream from "effect/Stream";
8import type * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";10import type * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
9import { describe, expect, it } from "vite-plus/test";11import { describe, expect, it } from "vite-plus/test";
10import type * as EffectAcpSchema from "effect-acp/schema";12import type * as EffectAcpSchema from "effect-acp/schema";
11import type { CursorSettings } from "@t3tools/contracts";13import { ProviderDriverKind, ProviderInstanceId, type CursorSettings } from "@t3tools/contracts";
12import { createModelCapabilities } from "@t3tools/shared/model";14import { createModelCapabilities } from "@t3tools/shared/model";
1315
14import {16import {
...@@ -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});
477480
481describe("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
478describe("buildCursorProviderSnapshot", () => {562describe("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";
22import * as Path from "effect/Path";22import * as Path from "effect/Path";
23import * as Result from "effect/Result";23import * as Result from "effect/Result";
24import * as Schema from "effect/Schema";24import * as Schema from "effect/Schema";
25import * as Stream from "effect/Stream";
26import * as SubscriptionRef from "effect/SubscriptionRef";
25import { HttpClient } from "effect/unstable/http";27import { HttpClient } from "effect/unstable/http";
26import * as ChildProcess from "effect/unstable/process/ChildProcess";28import * as ChildProcess from "effect/unstable/process/ChildProcess";
27import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";29import * 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";
50import * as AcpSessionRuntime from "../acp/AcpSessionRuntime.ts";52import * as AcpSessionRuntime from "../acp/AcpSessionRuntime.ts";
51import { CursorListAvailableModelsResponse } from "../acp/CursorAcpExtension.ts";53import { CursorListAvailableModelsResponse } from "../acp/CursorAcpExtension.ts";
54import type { ServerProviderShape } from "../Services/ServerProvider.ts";
55
56/** Session command catalogs stay scoped to their workspace across health refreshes. */
57export 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});
52142
53const decodeCursorListAvailableModelsResponse = Schema.decodeUnknownEffect(143const 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 }
294294
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 = requests350 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) => {
571580
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);
646661
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
15051505
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 = currentModelId1623 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";
19import { execScriptSource, writeFakeCli } from "../../testUtils/fakeCli.ts";20import { execScriptSource, writeFakeCli } from "../../testUtils/fakeCli.ts";
...@@ -56,6 +57,69 @@ describe("parseGrokModelsCliOutput", () => {...@@ -56,6 +57,69 @@ describe("parseGrokModelsCliOutput", () => {
56 });57 });
57});58});
5859
60describe("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
59describe("buildGrokModelsFromSessionModelState", () => {123describe("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 );
443508
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";
9import type * as EffectAcpSchema from "effect-acp/schema";10import * as EffectAcpSchema from "effect-acp/schema";
10import { causeErrorTag } from "@t3tools/shared/observability";11import { causeErrorTag } from "@t3tools/shared/observability";
11import * as Crypto from "effect/Crypto";12import * as Crypto from "effect/Crypto";
12import * as DateTime from "effect/DateTime";13import * 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";
14import * as Exit from "effect/Exit";15import * as Exit from "effect/Exit";
15import * as Option from "effect/Option";16import * as Option from "effect/Option";
16import * as Result from "effect/Result";17import * as Result from "effect/Result";
18import * as Schema from "effect/Schema";
17import { HttpClient } from "effect/unstable/http";19import { HttpClient } from "effect/unstable/http";
18import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";20import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
19import { createModelCapabilities } from "@t3tools/shared/model";21import { createModelCapabilities } from "@t3tools/shared/model";
...@@ -307,11 +309,41 @@ const runGrokCliCommand = (...@@ -307,11 +309,41 @@ const runGrokCliCommand = (
307 );309 );
308 });310 });
309311
312const decodeAvailableCommands = Schema.decodeUnknownOption(Schema.Array(Schema.Unknown));
313const decodeAvailableCommand = Schema.decodeUnknownOption(EffectAcpSchema.AvailableCommand);
314
315export 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 */
314const discoverGrokModelsViaAcpInitialize = (346const 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);
330365
331export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(function* (366export 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 );
463498
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 );
14451460
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 }
13931404
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.id2334 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 }
30943118
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 model3127 // 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 }
32123245
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 !== turnId3308 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.void3341 ? 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;
33573461
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 }
35293633
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";
20import * as OpenCodeServerOwner from "../OpenCodeServerOwner.ts";20import * as OpenCodeServerOwner from "../OpenCodeServerOwner.ts";
21import { checkOpenCodeProviderStatus } from "./OpenCodeProvider.ts";21import {
22 checkOpenCodeProviderStatus,
23 openCodeCommandsToServerProviderSlashCommands,
24} from "./OpenCodeProvider.ts";
22import type { OpenCodeInventory } from "../opencodeRuntime.ts";25import type { OpenCodeInventory } from "../opencodeRuntime.ts";
23const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings);26const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings);
2427
...@@ -163,6 +166,22 @@ beforeEach(() => {...@@ -163,6 +166,22 @@ beforeEach(() => {
163 runtimeMock.reset();166 runtimeMock.reset();
164});167});
165168
169it("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
166const testLayer = Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble).pipe(185const 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";
7import * as Cause from "effect/Cause";8import * as Cause from "effect/Cause";
8import * as Data from "effect/Data";9import * 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}
317318
319export 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
318export const makePendingOpenCodeProvider = (339export 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());
362363
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 });
911928
...@@ -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 });
4848
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);
5252
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 );
6061
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
4import {4import {
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}
191193
194export type OpenCodeSlashCommand = Pick<Command, "name" | "description" | "source" | "hints">;
195
196/** Command templates stay in OpenCode, which expands arguments and runs MCP prompts. */
197export 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
192export interface ParsedOpenCodeModelSlug {209export 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> => []));
924941
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 );
929961
930 const loadInventoryFromCli: OpenCodeRuntimeShape["loadInventoryFromCli"] = (input) =>962 const loadInventoryFromCli: OpenCodeRuntimeShape["loadInventoryFromCli"] = (input) =>
931 Effect.gen(function* () {963 Effect.gen(function* () {