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 {
2929import {
3030 dedupeProviderSkillsByName,
3131 getProviderSkillsForSlashMenu,
32 getProviderSlashCommandsForSlashMenu,
3233 isProviderSkillUserInvocable,
3334 resolveProviderSkillsForCwd,
35 resolveProviderSlashCommandsForCwd,
3436} from "@t3tools/client-runtime/providerSkills";
3537import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3638
......@@ -329,6 +331,7 @@ export function useComposerCommandMenu({
329331
330332 if (trigger.kind === "slash-command") {
331333 const q = trigger.query.toLowerCase();
334 const visibleSkills = getProviderSkillsForSlashMenu(skills, true);
332335 const commandItems = buildComposerSlashCommandItems({
333336 query: q,
334337 atMessageStart: trigger.rangeStart === 0,
......@@ -336,10 +339,18 @@ export function useComposerCommandMenu({
336339 hasCompactableConversation,
337340 offersUsageLimits,
338341 allowInteractionMode: onUpdateInteractionMode !== undefined,
339 selectedProviderStatus,
342 selectedProviderStatus: selectedProviderStatus
343 ? {
344 ...selectedProviderStatus,
345 slashCommands: getProviderSlashCommandsForSlashMenu(
346 resolveProviderSlashCommandsForCwd(selectedProviderStatus, projectCwd),
347 visibleSkills,
348 ),
349 }
350 : null,
340351 });
341352
342 const skillItems = getProviderSkillsForSlashMenu(skills, true)
353 const skillItems = visibleSkills
343354 .filter((skill) => matchesSlashSkillQuery(skill, q))
344355 .map((skill) => ({
345356 id: `skill:${skill.name}`,
......@@ -456,6 +467,7 @@ export function useComposerCommandMenu({
456467 onUpdateInteractionMode,
457468 pathSearch.entries,
458469 pullRequestSearch.entries,
470 projectCwd,
459471 selectedProviderStatus,
460472 skills,
461473 trigger,
apps/server/scripts/acp-mock-agent.ts+10-8
......@@ -784,7 +784,7 @@ const program = Effect.gen(function* () {
784784 sessionId: requestedSessionId,
785785 update: {
786786 sessionUpdate: "agent_message_chunk",
787 content: { type: "text", text: "hello from " },
787 content: { type: "text", text: "hello from" },
788788 },
789789 });
790790
......@@ -826,13 +826,15 @@ const program = Effect.gen(function* () {
826826 });
827827 }
828828
829 writeJsonRpcNotification("session/update", {
830 sessionId: requestedSessionId,
831 update: {
832 sessionUpdate: "agent_message_chunk",
833 content: { type: "text", text: "mock" },
834 },
835 });
829 for (const text of [" ", "mo", "ck"]) {
830 writeJsonRpcNotification("session/update", {
831 sessionId: requestedSessionId,
832 update: {
833 sessionUpdate: "agent_message_chunk",
834 content: { type: "text", text },
835 },
836 });
837 }
836838
837839 return yield* Effect.never;
838840 }
apps/server/src/provider/Drivers/CursorDriver.ts+32-22
......@@ -31,6 +31,7 @@ import {
3131 checkCursorProviderStatus,
3232 makeCursorModelDiscovery,
3333 enrichCursorSnapshot,
34 makeCursorCommandCatalog,
3435} from "../Layers/CursorProvider.ts";
3536import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts";
3637import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
......@@ -53,7 +54,7 @@ import {
5354 makeProviderSnapshotSettingsSource,
5455 type ProviderSnapshotSettings,
5556} from "../providerUpdateSettings.ts";
56import { probeCursorSkills } from "./CursorSkills.ts";
57import { discoverCursorSkills, probeCursorSkills } from "./CursorSkills.ts";
5758const decodeCursorSettings = Schema.decodeSync(CursorSettings);
5859
5960const DRIVER_KIND = ProviderDriverKind.make("cursor");
......@@ -130,11 +131,6 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = {
130131 ),
131132 );
132133
133 const adapter = yield* makeCursorAdapter(effectiveConfig, {
134 environment: processEnv,
135 ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}),
136 instanceId,
137 });
138134 const textGeneration = yield* makeCursorTextGeneration(effectiveConfig, processEnv);
139135
140136 const discoverModels = yield* makeCursorModelDiscovery(effectiveConfig, processEnv);
......@@ -151,7 +147,9 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = {
151147 );
152148
153149 const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings);
154 const snapshot = yield* makeManagedServerProvider<ProviderSnapshotSettings<CursorSettings>>({
150 const managedSnapshot = yield* makeManagedServerProvider<
151 ProviderSnapshotSettings<CursorSettings>
152 >({
155153 resolveMaintenance,
156154 getSettings: snapshotSettings.getSettings,
157155 streamSettings: snapshotSettings.streamSettings,
......@@ -188,6 +186,20 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = {
188186 ),
189187 );
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
191203 return {
192204 instanceId,
193205 driverKind: DRIVER_KIND,
......@@ -199,22 +211,20 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = {
199211 snapshotForCwd: (cwd) =>
200212 !effectiveConfig.enabled
201213 ? snapshot.getSnapshot
202 : Effect.all([
203 snapshot.getSnapshot,
204 probeCursorSkills(cwd, processEnv).pipe(
205 Effect.provideService(FileSystem.FileSystem, fileSystem),
206 Effect.provideService(Path.Path, path),
207 Effect.mapError(
208 (cause) =>
209 new ProviderDriverError({
210 driver: DRIVER_KIND,
211 instanceId,
212 detail: `Failed to discover Cursor skills for '${cwd}'`,
213 cause,
214 }),
215 ),
214 : probeCursorSkills(cwd, processEnv).pipe(
215 Effect.provideService(FileSystem.FileSystem, fileSystem),
216 Effect.provideService(Path.Path, path),
217 Effect.mapError(
218 (cause) =>
219 new ProviderDriverError({
220 driver: DRIVER_KIND,
221 instanceId,
222 detail: `Failed to discover Cursor skills for '${cwd}'`,
223 cause,
224 }),
216225 ),
217 ]).pipe(Effect.map(([machineSnapshot, skills]) => ({ ...machineSnapshot, skills }))),
226 Effect.flatMap((skills) => snapshotForCwd(cwd, skills)),
227 ),
218228 adapter,
219229 textGeneration,
220230 } satisfies ProviderInstance;
apps/server/src/provider/Drivers/OpenCodeDriver.ts+20-7
......@@ -31,10 +31,11 @@ import {
3131 checkOpenCodeProviderStatus,
3232 makePendingOpenCodeProvider,
3333 openCodeSkillsToServerProviderSkills,
34 openCodeCommandsToServerProviderSlashCommands,
3435} from "../Layers/OpenCodeProvider.ts";
3536import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts";
3637import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
37import { OpenCodeRuntime } from "../opencodeRuntime.ts";
38import { OpenCodeRuntime, loadOpenCodeCommands } from "../opencodeRuntime.ts";
3839import * as OpenCodeServerOwner from "../OpenCodeServerOwner.ts";
3940import {
4041 defaultProviderContinuationIdentity,
......@@ -164,7 +165,18 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv>
164165 // empty skill list and poisons the workspace snapshot the `$` picker
165166 // reads. The SDK `app.skills` endpoint honors the per-request directory
166167 // 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) =>
168180 effectiveConfig.serverUrl.trim().length > 0
169181 ? Effect.scoped(
170182 Effect.gen(function* () {
......@@ -184,11 +196,11 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv>
184196 ? { serverPassword: effectiveConfig.serverPassword }
185197 : {}),
186198 });
187 return yield* openCodeRuntime.loadOpenCodeSkills(client);
199 return yield* loadWorkspaceInventory(client);
188200 }),
189201 )
190202 : serverOwner.withServer((server) =>
191 openCodeRuntime.loadOpenCodeSkills(
203 loadWorkspaceInventory(
192204 openCodeRuntime.createOpenCodeSdkClient({
193205 baseUrl: server.url,
194206 directory: cwd,
......@@ -247,18 +259,19 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv>
247259 ? snapshot.getSnapshot
248260 : Effect.all([
249261 snapshot.getSnapshot,
250 loadSkillsForCwd(cwd).pipe(Effect.timeout("20 seconds")),
262 loadWorkspaceForCwd(cwd).pipe(Effect.timeout("20 seconds")),
251263 ]).pipe(
252 Effect.map(([machineSnapshot, skills]) => ({
264 Effect.map(([machineSnapshot, { skills, commands }]) => ({
253265 ...machineSnapshot,
254266 skills: openCodeSkillsToServerProviderSkills(skills),
267 slashCommands: openCodeCommandsToServerProviderSlashCommands(commands),
255268 })),
256269 Effect.mapError(
257270 (cause) =>
258271 new ProviderDriverError({
259272 driver: DRIVER_KIND,
260273 instanceId,
261 detail: `Failed to probe OpenCode skills for '${cwd}'`,
274 detail: `Failed to probe OpenCode commands and skills for '${cwd}'`,
262275 cause,
263276 }),
264277 ),
apps/server/src/provider/Layers/CursorAdapter.test.ts+3-1
......@@ -302,7 +302,7 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => {
302302 }),
303303 );
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", () =>
306306 Effect.gen(function* () {
307307 const adapter = yield* CursorAdapter;
308308 const settings = yield* ServerSettingsService;
......@@ -347,6 +347,7 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => {
347347 ],
348348 ],
349349 );
350 yield* adapter.sendTurn({ threadId, input: "/copy-request-id" });
350351 yield* adapter.stopSession(threadId);
351352
352353 const requests = yield* Effect.promise(() => readJsonLines(requestLogPath));
......@@ -360,6 +361,7 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => {
360361 { type: "text", text: "please /review this" },
361362 { type: "text", text: buildRuntimeInstructions({ harness: "Cursor" }) },
362363 ],
364 [{ type: "text", text: "/copy-request-id" }],
363365 ],
364366 );
365367 }),
apps/server/src/provider/Layers/CursorAdapter.ts+20-8
......@@ -118,6 +118,10 @@ export interface CursorAdapterLiveOptions {
118118 * the latest snapshot so the closure isn't stale.
119119 */
120120 readonly resolveSettings?: Effect.Effect<CursorSettings>;
121 readonly onAvailableCommands?: (
122 commands: ReadonlyArray<EffectAcpSchema.AvailableCommand>,
123 cwd: string,
124 ) => Effect.Effect<void>;
121125}
122126
123127interface PendingApproval {
......@@ -810,6 +814,11 @@ export function makeCursorAdapter(
810814 return;
811815 case "ModeChanged":
812816 return;
817 case "AvailableCommandsUpdated":
818 yield* (
819 options?.onAvailableCommands?.(event.availableCommands, cwd) ?? Effect.void
820 );
821 return;
813822 case "AssistantItemStarted":
814823 ctx.assistantReply = new CursorTransportFailure();
815824 yield* offerRuntimeEvent(
......@@ -1057,16 +1066,19 @@ export function makeCursorAdapter(
10571066 });
10581067 }
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.
10611071 const result = yield* ctx.acp
10621072 .prompt({
1063 prompt: [
1064 ...promptParts,
1065 {
1066 type: "text",
1067 text: buildRuntimeInstructions({ harness: "Cursor", model: resolvedModel }),
1068 },
1069 ],
1073 prompt: /^\/[^\s/]+(?:\s|$)/.test(rawPrompt)
1074 ? promptParts
1075 : [
1076 ...promptParts,
1077 {
1078 type: "text",
1079 text: buildRuntimeInstructions({ harness: "Cursor", model: resolvedModel }),
1080 },
1081 ],
10701082 })
10711083 .pipe(
10721084 Effect.mapError((error) =>
apps/server/src/provider/Layers/CursorProvider.test.ts+85-1
......@@ -1,14 +1,16 @@
11import * as NodeOS from "node:os";
22
33import * as NodeServices from "@effect/platform-node/NodeServices";
4import { it as effectIt } from "@effect/vitest";
45import type * as Crypto from "effect/Crypto";
56import * as Effect from "effect/Effect";
67import * as FileSystem from "effect/FileSystem";
78import * as Path from "effect/Path";
9import * as Stream from "effect/Stream";
810import type * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
911import { describe, expect, it } from "vite-plus/test";
1012import type * as EffectAcpSchema from "effect-acp/schema";
11import type { CursorSettings } from "@t3tools/contracts";
13import { ProviderDriverKind, ProviderInstanceId, type CursorSettings } from "@t3tools/contracts";
1214import { createModelCapabilities } from "@t3tools/shared/model";
1315
1416import {
......@@ -17,6 +19,7 @@ import {
1719 checkCursorProviderStatus,
1820 discoverCursorModelsViaAcp,
1921 makeCursorModelDiscovery,
22 makeCursorCommandCatalog,
2023 getCursorParameterizedModelPickerUnsupportedMessage,
2124 parseCursorAboutOutput,
2225 parseCursorCliConfigChannel,
......@@ -475,6 +478,87 @@ describe("Cursor skills", () => {
475478 });
476479});
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
478562describe("buildCursorProviderSnapshot", () => {
479563 it("downgrades ready status to warning when ACP model discovery times out", () => {
480564 expect(
apps/server/src/provider/Layers/CursorProvider.ts+90
......@@ -22,6 +22,8 @@ import * as Option from "effect/Option";
2222import * as Path from "effect/Path";
2323import * as Result from "effect/Result";
2424import * as Schema from "effect/Schema";
25import * as Stream from "effect/Stream";
26import * as SubscriptionRef from "effect/SubscriptionRef";
2527import { HttpClient } from "effect/unstable/http";
2628import * as ChildProcess from "effect/unstable/process/ChildProcess";
2729import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
......@@ -49,6 +51,94 @@ import {
4951} from "../providerMaintenance.ts";
5052import * as AcpSessionRuntime from "../acp/AcpSessionRuntime.ts";
5153import { 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
53143const decodeCursorListAvailableModelsResponse = Schema.decodeUnknownEffect(
54144 CursorListAvailableModelsResponse,
apps/server/src/provider/Layers/GrokAdapter.test.ts+63-5
......@@ -292,7 +292,7 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => {
292292 );
293293 }
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", () =>
296296 Effect.gen(function* () {
297297 const threadId = ThreadId.make("grok-runtime-context");
298298 const tempDir = yield* Effect.promise(() =>
......@@ -337,6 +337,14 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => {
337337 ],
338338 ],
339339 );
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" });
340348 yield* adapter.stopSession(threadId);
341349 const requests = yield* Effect.promise(() => readJsonLines(requestLogPath));
342350 const prompts = requests
......@@ -344,7 +352,8 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => {
344352 .map(
345353 (request) => (request.params as { prompt: Array<{ type: string; text: string }> }).prompt,
346354 );
347 assert.equal(prompts.length, 2);
355 assert.equal(prompts.length, 3);
356 assert.deepEqual(prompts[2], [{ type: "text", text: "/goal status" }]);
348357 assert.deepEqual(prompts[0]?.[0], { type: "text", text: "First prompt" });
349358 assert.include(prompts[0]?.[1]?.text, "Grok harness, as grok-mock-alt");
350359 assert.deepEqual(prompts[1]?.[0], { type: "text", text: "Second prompt" });
......@@ -571,13 +580,19 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => {
571580
572581 const runtimeEvents: ProviderRuntimeEvent[] = [];
573582 const turnCompleted = yield* Deferred.make<void>();
583 const secondTurnCompleted = yield* Deferred.make<void>();
574584 const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) =>
575585 Effect.sync(() => {
576586 runtimeEvents.push(event);
577587 }).pipe(
578 Effect.andThen(
588 Effect.andThen(() =>
579589 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 )
581596 : Effect.void,
582597 ),
583598 ),
......@@ -644,6 +659,44 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => {
644659 assert.equal(readySession?.status, "ready");
645660 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
647700 yield* Fiber.interrupt(runtimeEventsFiber);
648701 yield* adapter.stopSession(threadId);
649702 }),
......@@ -1669,6 +1722,7 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => {
16691722 const runtimeEvents: ProviderRuntimeEvent[] = [];
16701723 const activeTurnIdRef = yield* Ref.make<TurnId | undefined>(undefined);
16711724 const trailingChunkTurnId = yield* Deferred.make<TurnId>();
1725 let receivedText = "";
16721726 const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) =>
16731727 Effect.gen(function* () {
16741728 runtimeEvents.push(event);
......@@ -1678,7 +1732,11 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => {
16781732 if (event.type === "turn.started") {
16791733 yield* Ref.set(activeTurnIdRef, event.turnId);
16801734 }
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") {
16821740 return;
16831741 }
16841742 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
15051505
15061506 const sendTurn: GrokAdapterShape["sendTurn"] = (input) =>
15071507 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 }
15081515 const prepared = yield* withThreadLock(
15091516 input.threadId,
15101517 Effect.gen(function* () {
......@@ -1616,11 +1623,15 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
16161623 const displayModel = currentModelId
16171624 ? resolveGrokAcpBaseModelId(currentModelId)
16181625 : undefined;
1619 const runtimeInstructions = buildRuntimeInstructions({
1620 harness: "Grok",
1621 model: displayModel,
1622 reasoningEffort: normalizeGrokReasoningEffort(requestedTurnReasoningEffort),
1623 });
1626 // ACP slash commands must receive only their own arguments.
1627 const runtimeInstructions =
1628 text && /^\/[^\s/]+(?:\s|$)/.test(text)
1629 ? undefined
1630 : buildRuntimeInstructions({
1631 harness: "Grok",
1632 model: displayModel,
1633 reasoningEffort: normalizeGrokReasoningEffort(requestedTurnReasoningEffort),
1634 });
16241635 for (let yieldAttempt = 0; yieldAttempt < 8; yieldAttempt += 1) {
16251636 yield* Effect.yieldNow;
16261637 }
......@@ -1737,7 +1748,9 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
17371748 {
17381749 prompt: [
17391750 ...prepared.promptParts,
1740 { type: "text", text: prepared.runtimeInstructions },
1751 ...(prepared.runtimeInstructions
1752 ? [{ type: "text" as const, text: prepared.runtimeInstructions }]
1753 : []),
17411754 ],
17421755 },
17431756 { dispatched },
apps/server/src/provider/Layers/GrokProvider.test.ts+65
......@@ -14,6 +14,7 @@ import {
1414 buildGrokModelsFromSessionModelState,
1515 buildInitialGrokProviderSnapshot,
1616 checkGrokProviderStatus,
17 grokSlashCommandsFromInitialize,
1718 parseGrokModelsCliOutput,
1819} from "./GrokProvider.ts";
1920import { execScriptSource, writeFakeCli } from "../../testUtils/fakeCli.ts";
......@@ -56,6 +57,69 @@ describe("parseGrokModelsCliOutput", () => {
5657 });
5758});
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
59123describe("buildGrokModelsFromSessionModelState", () => {
60124 it("marks the agent's current model as default and keeps reasoning options", () => {
61125 const models = buildGrokModelsFromSessionModelState({
......@@ -438,6 +502,7 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => {
438502 ["grok-4.5", false],
439503 ]);
440504 expect(snapshot.message).toContain("ACP initialize failed");
505 expect(snapshot.slashCommands.map((command) => command.name)).toEqual(["compact"]);
441506 }),
442507 );
443508
apps/server/src/provider/Layers/GrokProvider.ts+43-7
......@@ -5,8 +5,9 @@ import {
55 type ServerProvider,
66 type ServerProviderAuth,
77 type ServerProviderModel,
8 type ServerProviderSlashCommand,
89} from "@t3tools/contracts";
9import type * as EffectAcpSchema from "effect-acp/schema";
10import * as EffectAcpSchema from "effect-acp/schema";
1011import { causeErrorTag } from "@t3tools/shared/observability";
1112import * as Crypto from "effect/Crypto";
1213import * as DateTime from "effect/DateTime";
......@@ -14,6 +15,7 @@ import * as Effect from "effect/Effect";
1415import * as Exit from "effect/Exit";
1516import * as Option from "effect/Option";
1617import * as Result from "effect/Result";
18import * as Schema from "effect/Schema";
1719import { HttpClient } from "effect/unstable/http";
1820import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
1921import { createModelCapabilities } from "@t3tools/shared/model";
......@@ -307,11 +309,41 @@ const runGrokCliCommand = (
307309 );
308310 });
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
310342/**
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`
312344 * or `session/new`, so it cannot open a browser login or boot the workspace's MCP servers.
313345 */
314const discoverGrokModelsViaAcpInitialize = (
346const discoverGrokMetadataViaAcpInitialize = (
315347 grokSettings: GrokSettings,
316348 environment: NodeJS.ProcessEnv,
317349) =>
......@@ -325,7 +357,10 @@ const discoverGrokModelsViaAcpInitialize = (
325357 clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" },
326358 });
327359 const initialized = yield* acp.initialize();
328 return buildGrokModelsFromSessionModelState(sessionModelStateFromInitialize(initialized));
360 return {
361 models: buildGrokModelsFromSessionModelState(sessionModelStateFromInitialize(initialized)),
362 slashCommands: grokSlashCommandsFromInitialize(initialized),
363 };
329364 }).pipe(Effect.scoped);
330365
331366export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(function* (
......@@ -461,11 +496,12 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func
461496 Effect.orElseSucceed(() => []),
462497 );
463498
464 const acpExit = yield* discoverGrokModelsViaAcpInitialize(grokSettings, environment).pipe(
499 const acpExit = yield* discoverGrokMetadataViaAcpInitialize(grokSettings, environment).pipe(
465500 Effect.timeoutOption(GROK_ACP_INITIALIZE_TIMEOUT_MS),
466501 Effect.exit,
467502 );
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 ?? [];
469505 const acpFailed = Exit.isFailure(acpExit) || Option.isNone(acpExit.value);
470506 if (acpFailed) {
471507 yield* Effect.logWarning("Grok ACP initialize probe failed or timed out.", {
......@@ -502,7 +538,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func
502538 checkedAt,
503539 models,
504540 skills,
505 slashCommands: [COMPACT_SLASH_COMMAND],
541 slashCommands: acpMetadata?.slashCommands ?? [COMPACT_SLASH_COMMAND],
506542 probe: {
507543 installed: true,
508544 version,
apps/server/src/provider/Layers/OpenCodeAdapter.test.ts+272
......@@ -88,6 +88,10 @@ const runtimeMock = {
8888 messageCalls: [] as Array<{ sessionID: string; messageID: string }>,
8989 messageFailures: 0,
9090 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,
9195 summarizeCalls: [] as Array<unknown>,
9296 promptAsyncError: null as Error | null,
9397 promptAsyncImplementation: null as (() => Promise<void>) | null,
......@@ -150,6 +154,8 @@ const runtimeMock = {
150154 this.state.messageCalls.length = 0;
151155 this.state.messageFailures = 0;
152156 this.state.promptCalls.length = 0;
157 this.state.commandCalls.length = 0;
158 this.state.commandImplementation = null;
153159 this.state.summarizeCalls.length = 0;
154160 this.state.promptAsyncError = null;
155161 this.state.promptAsyncImplementation = null;
......@@ -236,6 +242,11 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
236242 runOpenCodeCommand: () => Effect.succeed({ stdout: "", stderr: "", code: 0 }),
237243 createOpenCodeSdkClient: ({ baseUrl, serverPassword }) =>
238244 ({
245 command: {
246 list: async () => ({
247 data: [{ name: "review", source: "command", hints: ["$ARGUMENTS"] }],
248 }),
249 },
239250 session: {
240251 create: async (input: Record<string, unknown>) => {
241252 runtimeMock.state.sessionCreateUrls.push(baseUrl);
......@@ -354,6 +365,10 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
354365 : { "http://127.0.0.1:9999/session": { type: "busy" as const } },
355366 };
356367 },
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 },
357372 promptAsync: async (input: unknown) => {
358373 runtimeMock.state.promptCalls.push(input);
359374 await runtimeMock.state.promptAsyncImplementation?.();
......@@ -1443,6 +1458,263 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
14431458 }),
14441459 );
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
14461718 it.effect("rolls back session state when sendTurn fails before OpenCode accepts the prompt", () =>
14471719 Effect.gen(function* () {
14481720 const adapter = yield* OpenCodeAdapter;
apps/server/src/provider/Layers/OpenCodeAdapter.ts+134-28
......@@ -51,6 +51,7 @@ import {
5151 OpenCodeRuntimeError,
5252 openCodeQuestionId,
5353 openCodeRuntimeErrorDetail,
54 loadOpenCodeCommands,
5455 parseOpenCodeModelSlug,
5556 runOpenCodeSdk,
5657 toOpenCodeFileParts,
......@@ -215,6 +216,7 @@ interface OpenCodePromptAdmission {
215216 readonly generation: number;
216217 readonly turnId: TurnId;
217218 readonly messageId: string;
219 requiresMessageReceipt: boolean;
218220 readonly priorAwaitingBusy: boolean;
219221 readonly priorIdle: { readonly turnId: TurnId; readonly raw: unknown } | undefined;
220222 idleDuringAdmission: { readonly turnId: TurnId; readonly raw: unknown } | undefined;
......@@ -225,6 +227,7 @@ interface OpenCodePromptAdmission {
225227 accepted: boolean;
226228 cancelled: boolean;
227229 readonly acceptance: Deferred.Deferred<void>;
230 readonly messageReceipt: Deferred.Deferred<void>;
228231 readonly submissionSettled: Deferred.Deferred<void>;
229232 promptFiber?: Fiber.Fiber<void, ProviderAdapterRequestError>;
230233 recoveryFiber?: Fiber.Fiber<void, never>;
......@@ -362,6 +365,7 @@ interface OpenCodeSessionContext {
362365 pendingRequestRecovery: OpenCodePendingRequestRecovery | undefined;
363366 promptGeneration: number;
364367 promptAdmission: OpenCodePromptAdmission | undefined;
368 readonly commandFibers: Set<Fiber.Fiber<void, ProviderAdapterRequestError>>;
365369 readonly promptSemaphore: Semaphore.Semaphore;
366370 readonly firstConnection: Deferred.Deferred<void, ProviderAdapterRequestError>;
367371 /**
......@@ -1350,8 +1354,14 @@ export function makeOpenCodeAdapter(
13501354 return;
13511355 }
13521356 const recover = Effect.gen(function* () {
1353 yield* Deferred.await(promptAdmission.acceptance);
1354 for (let retryCount = 0; retryCount < 5; retryCount += 1) {
1357 if (!promptAdmission.requiresMessageReceipt) {
1358 yield* Deferred.await(promptAdmission.acceptance);
1359 }
1360 for (
1361 let retryCount = 0;
1362 retryCount < 5 || (promptAdmission.requiresMessageReceipt && !promptAdmission.accepted);
1363 retryCount += 1
1364 ) {
13551365 if (
13561366 context.promptAdmission !== promptAdmission ||
13571367 context.activeTurnId !== promptAdmission.turnId ||
......@@ -1386,11 +1396,23 @@ export function makeOpenCodeAdapter(
13861396 const message = Option.isSome(response) ? response.value.data : undefined;
13871397 if (message?.info.id === promptAdmission.messageId && message.info.role === "user") {
13881398 promptAdmission.messageObserved = true;
1399 yield* Deferred.succeed(promptAdmission.messageReceipt, undefined);
13891400 context.messageRoleById.set(promptAdmission.messageId, "user");
13901401 context.textPartsByMessageId.delete(promptAdmission.messageId);
13911402 }
13921403 }
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
13941416 const statusResponse = yield* runOpenCodeSdk("session.status", (signal) =>
13951417 context.client.session.status(undefined, { signal }),
13961418 ).pipe(Effect.timeout("1 second"), Effect.option);
......@@ -2312,6 +2334,7 @@ export function makeOpenCodeAdapter(
23122334 promptAdmission?.messageId === event.properties.info.id
23132335 ) {
23142336 promptAdmission.messageObserved = true;
2337 yield* Deferred.succeed(promptAdmission.messageReceipt, undefined);
23152338 if (promptAdmission.accepted) {
23162339 const idle = promptAdmission.idleDuringAdmission;
23172340 context.awaitingBusyAfterInterruption = false;
......@@ -3006,6 +3029,7 @@ export function makeOpenCodeAdapter(
30063029 pendingRequestRecovery: undefined,
30073030 promptGeneration: 0,
30083031 promptAdmission: undefined,
3032 commandFibers: new Set(),
30093033 promptSemaphore: Semaphore.makeUnsafe(1),
30103034 firstConnection: Deferred.makeUnsafe<void, ProviderAdapterRequestError>(),
30113035 stopped: yield* Ref.make(false),
......@@ -3093,6 +3117,13 @@ export function makeOpenCodeAdapter(
30933117 }
30943118
30953119 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;
30963127 // OpenCode ingests images, text, and PDFs natively; formats its model
30973128 // paths reject ride only as the prompt's file path line.
30983129 const fileParts = toOpenCodeFileParts({
......@@ -3150,6 +3181,7 @@ export function makeOpenCodeAdapter(
31503181 generation: promptGeneration,
31513182 turnId,
31523183 messageId,
3184 requiresMessageReceipt: nativeCommand !== undefined,
31533185 priorAwaitingBusy,
31543186 priorIdle: priorIdleCandidate,
31553187 idleDuringAdmission: undefined,
......@@ -3160,6 +3192,7 @@ export function makeOpenCodeAdapter(
31603192 accepted: false,
31613193 cancelled: false,
31623194 acceptance: Deferred.makeUnsafe<void>(),
3195 messageReceipt: Deferred.makeUnsafe<void>(),
31633196 submissionSettled: Deferred.makeUnsafe<void>(),
31643197 recoveryRaw: undefined,
31653198 };
......@@ -3211,25 +3244,52 @@ export function makeOpenCodeAdapter(
32113244 }
32123245
32133246 let promptTimedOut = false;
3214 const promptEffect = runOpenCodeSdk("session.promptAsync", (signal) =>
3215 context.client.session.promptAsync(
3216 {
3217 sessionID: context.openCodeSessionId,
3218 messageID: messageId,
3219 model: parsedModel,
3220 ...(context.activeAgent ? { agent: context.activeAgent } : {}),
3221 ...(context.activeVariant ? { variant: context.activeVariant } : {}),
3222 // OpenCode appends this after its own agent/provider prompts.
3223 system: buildRuntimeInstructions({
3224 harness: "OpenCode",
3225 model: `${parsedModel.providerID}/${parsedModel.modelID}`,
3226 }),
3227 parts: [...(text ? [{ type: "text" as const, text }] : []), ...fileParts],
3228 },
3229 { signal },
3230 ),
3231 ).pipe(
3232 Effect.timeout("10 seconds"),
3247 const submissionMethod = nativeCommand ? "session.command" : "session.promptAsync";
3248 // Native commands expand provider-owned templates. Their API does not
3249 // accept the per-turn system addendum supported by ordinary prompts.
3250 const submission = nativeCommand
3251 ? Effect.raceFirst(
3252 runOpenCodeSdk("session.command", (signal) =>
3253 context.client.session.command(
3254 {
3255 sessionID: context.openCodeSessionId,
3256 messageID: messageId,
3257 command: nativeCommand.name,
3258 arguments: commandMatch?.[2] ?? "",
3259 model: `${parsedModel.providerID}/${parsedModel.modelID}`,
3260 ...(context.activeAgent ? { agent: context.activeAgent } : {}),
3261 ...(context.activeVariant ? { variant: context.activeVariant } : {}),
3262 parts: fileParts,
3263 },
3264 { signal },
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(
32333293 Effect.catchTags({
32343294 OpenCodeRuntimeError: (cause) => Effect.fail(toRequestError(cause)),
32353295 TimeoutError: (cause) => {
......@@ -3237,15 +3297,47 @@ export function makeOpenCodeAdapter(
32373297 return Effect.fail(
32383298 new ProviderAdapterRequestError({
32393299 provider: PROVIDER,
3240 method: "session.promptAsync",
3300 method: submissionMethod,
32413301 detail: "OpenCode prompt submission did not complete within 10 seconds.",
32423302 cause,
32433303 }),
32443304 );
32453305 },
32463306 }),
3247 Effect.tapError((requestError) =>
3248 context.promptAdmission !== promptAdmission || context.activeTurnId !== turnId
3307 Effect.tapError(() => {
3308 promptAdmission.requiresMessageReceipt = false;
3309 return nativeCommand && promptAdmission.recoveryFiber
3310 ? Fiber.interrupt(promptAdmission.recoveryFiber)
3311 : Effect.void;
3312 }),
3313 Effect.tapError((requestError) => {
3314 if (
3315 nativeCommand &&
3316 (promptAdmission.cancelled || context.cancellation?.turnId === turnId)
3317 ) {
3318 return Effect.void;
3319 }
3320 if (
3321 nativeCommand &&
3322 promptAdmission.accepted &&
3323 context.activeTurnId === turnId &&
3324 (steeringTurnId !== undefined ||
3325 context.promptGeneration !== promptAdmission.generation)
3326 ) {
3327 return Effect.gen(function* () {
3328 yield* emit({
3329 ...(yield* buildEventBase({ threadId: input.threadId, turnId })),
3330 type: "runtime.warning",
3331 payload: {
3332 message: `OpenCode /${nativeCommand.name} failed after it was accepted.`,
3333 detail: requestError.detail,
3334 },
3335 });
3336 });
3337 }
3338 return (nativeCommand
3339 ? context.promptGeneration !== promptAdmission.generation
3340 : context.promptAdmission !== promptAdmission) || context.activeTurnId !== turnId
32493341 ? Effect.void
32503342 : Effect.gen(function* () {
32513343 if (!promptTimedOut) {
......@@ -3334,8 +3426,8 @@ export function makeOpenCodeAdapter(
33343426 tokenUsage,
33353427 },
33363428 });
3337 }),
3338 ),
3429 });
3430 }),
33393431 Effect.onExit((exit) =>
33403432 Effect.gen(function* () {
33413433 yield* Deferred.succeed(promptAdmission.submissionSettled, undefined).pipe(
......@@ -3352,8 +3444,20 @@ export function makeOpenCodeAdapter(
33523444 );
33533445 const promptFiber = yield* promptEffect.pipe(Effect.forkIn(context.sessionScope));
33543446 promptAdmission.promptFiber = promptFiber;
3355 const promptExit = yield* Effect.exit(Fiber.join(promptFiber));
3356 delete promptAdmission.promptFiber;
3447 if (nativeCommand) {
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
33583462 const intentionallyCancelled =
33593463 promptAdmission.cancelled ||
......@@ -3527,6 +3631,8 @@ export function makeOpenCodeAdapter(
35273631 yield* Deferred.await(promptAdmission.submissionSettled);
35283632 }
35293633
3634 yield* Effect.forEach([...context.commandFibers], Fiber.interrupt, { discard: true });
3635
35303636 const parentAbortOutcome = yield* Effect.raceFirst(
35313637 runOpenCodeSdk("session.abort", (signal) =>
35323638 context.client.session.abort({ sessionID: context.openCodeSessionId }, { signal }),
apps/server/src/provider/Layers/OpenCodeProvider.test.ts+20-1
......@@ -18,7 +18,10 @@ import {
1818 type OpenCodeRuntimeShape,
1919} from "../opencodeRuntime.ts";
2020import * as OpenCodeServerOwner from "../OpenCodeServerOwner.ts";
21import { checkOpenCodeProviderStatus } from "./OpenCodeProvider.ts";
21import {
22 checkOpenCodeProviderStatus,
23 openCodeCommandsToServerProviderSlashCommands,
24} from "./OpenCodeProvider.ts";
2225import type { OpenCodeInventory } from "../opencodeRuntime.ts";
2326const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings);
2427
......@@ -163,6 +166,22 @@ beforeEach(() => {
163166 runtimeMock.reset();
164167});
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
166185const testLayer = Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble).pipe(
167186 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
168187 Layer.provideMerge(NodeServices.layer),
apps/server/src/provider/Layers/OpenCodeProvider.ts+24-1
......@@ -3,6 +3,7 @@ import {
33 type OpenCodeSettings,
44 type ServerProviderModel,
55 type ServerProviderSkill,
6 type ServerProviderSlashCommand,
67} from "@t3tools/contracts";
78import * as Cause from "effect/Cause";
89import * as Data from "effect/Data";
......@@ -315,6 +316,26 @@ export function openCodeSkillsToServerProviderSkills(
315316 return skills.toSorted((left, right) => left.name.localeCompare(right.name));
316317}
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
318339export const makePendingOpenCodeProvider = (
319340 openCodeSettings: OpenCodeSettings,
320341): Effect.Effect<ServerProviderDraft> =>
......@@ -526,7 +547,9 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu
526547 checkedAt,
527548 models,
528549 skills,
529 slashCommands: [COMPACT_SLASH_COMMAND],
550 slashCommands: openCodeCommandsToServerProviderSlashCommands(
551 inventoryExit.value.inventory.commands,
552 ),
530553 probe: {
531554 installed: true,
532555 version,
apps/server/src/provider/acp/AcpSessionRuntime.ts+20-2
......@@ -358,6 +358,7 @@ export const make = (
358358 const promptSerializationSemaphore = yield* Semaphore.make(1);
359359 const promptDispatchSemaphore = yield* Semaphore.make(1);
360360 const activePromptRef = yield* Ref.make<Option.Option<AcpActivePrompt>>(Option.none());
361 const assistantUpdatesOpenRef = yield* Ref.make(true);
361362 const sessionLoadGateRef = yield* Ref.make<Option.Option<SessionLoadGate>>(Option.none());
362363
363364 const ensureConnected = Effect.gen(function* () {
......@@ -547,6 +548,13 @@ export const make = (
547548 ) {
548549 return;
549550 }
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 }
550558 yield* processSessionUpdate(notification);
551559 }),
552560 ),
......@@ -905,7 +913,16 @@ export const make = (
905913 return;
906914 }
907915 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 );
909926 yield* Effect.raceFirst(Deferred.await(acknowledge), Deferred.await(runtimeClosed));
910927 });
911928
......@@ -984,6 +1001,7 @@ export const make = (
9841001 Effect.gen(function* () {
9851002 const started = yield* getStartedState;
9861003 yield* closeActiveAssistantSegment({ queue: eventQueue, assistantSegmentRef });
1004 yield* Ref.set(assistantUpdatesOpenRef, true);
9871005 const requestPayload = {
9881006 sessionId: started.sessionId,
9891007 ...payload,
......@@ -1000,7 +1018,7 @@ export const make = (
10001018 yield* Deferred.succeed(promptOptions.dispatched, undefined);
10011019 }
10021020 return active;
1003 }),
1021 }).pipe(notificationSemaphore.withPermit),
10041022 ),
10051023 (activePrompt) =>
10061024 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) => {
4747 });
4848
4949 const inventoryFiber = yield* runtime.loadOpenCodeInventory(client).pipe(Effect.forkChild);
50 yield* Queue.takeN(started, 3);
50 yield* Queue.takeN(started, 4);
5151 yield* Fiber.interrupt(inventoryFiber);
5252
5353 NodeAssert.deepEqual((yield* Queue.takeAll(aborted)).toSorted(), [
5454 "/agent",
55 "/command",
5556 "/provider",
5657 "/skill",
5758 ]);
5859 }),
5960 );
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
61114 it.effect("keeps provider inventory when agent discovery fails", () =>
62115 Effect.gen(function* () {
63116 const runtime = yield* OpenCodeRuntime;
apps/server/src/provider/opencodeRuntime.ts+35-3
......@@ -4,6 +4,7 @@ import type { ChatAttachment, ProviderApprovalDecision, RuntimeMode } from "@t3t
44import {
55 createOpencodeClient,
66 type Agent,
7 type Command,
78 type FilePartInput,
89 type Model,
910 type OpencodeClient,
......@@ -187,8 +188,24 @@ export interface OpenCodeInventory {
187188 readonly providerList: ProviderListResponse;
188189 readonly agents: ReadonlyArray<Agent>;
189190 readonly skills: ReadonlyArray<OpenCodeSkill>;
191 readonly commands?: ReadonlyArray<OpenCodeSlashCommand>;
190192}
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
192209export interface ParsedOpenCodeModelSlug {
193210 readonly providerID: string;
194211 readonly modelID: string;
......@@ -923,9 +940,24 @@ const makeOpenCodeRuntime = Effect.gen(function* () {
923940 loadOpenCodeSkills(client).pipe(Effect.orElseSucceed((): ReadonlyArray<OpenCodeSkill> => []));
924941
925942 const loadOpenCodeInventory: OpenCodeRuntimeShape["loadOpenCodeInventory"] = (client) =>
926 Effect.all([loadProviders(client), loadAgents(client), loadSkills(client)], {
927 concurrency: "unbounded",
928 }).pipe(Effect.map(([providerList, agents, skills]) => ({ providerList, agents, skills })));
943 Effect.all(
944 [
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
930962 const loadInventoryFromCli: OpenCodeRuntimeShape["loadInventoryFromCli"] = (input) =>
931963 Effect.gen(function* () {