From 8c70947c267e35af8fd122778de3e057d6ce7417 Mon Sep 17 00:00:00 2001 From: Deyvid Bardarov Date: Mon, 30 Mar 2026 22:00:30 +0300 Subject: [PATCH 1/5] feat: cache provider slash commands on project creation and session start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discover provider slash commands eagerly on project creation via a lightweight Claude SDK probe query, and update the cache when a session reports its commands via session.configured. Commands are persisted server-side in the project projection (SQLite) and served to the web client through the orchestration snapshot, enabling a 3-tier fallback: session commands → project-level cache → localStorage cache. --- .../TestProviderAdapter.integration.ts | 1 + .../Layers/CheckpointDiffQuery.test.ts | 1 + .../Layers/CheckpointReactor.test.ts | 11 ++ .../Layers/OrchestrationEngine.ts | 1 + .../Layers/ProjectionPipeline.test.ts | 1 + .../Layers/ProjectionPipeline.ts | 26 ++++ .../Layers/ProjectionSnapshotQuery.test.ts | 2 + .../Layers/ProjectionSnapshotQuery.ts | 51 +++++-- .../Layers/ProviderCommandReactor.test.ts | 8 ++ .../Layers/ProviderCommandReactor.ts | 52 +++++++ .../Layers/ProviderRuntimeIngestion.test.ts | 8 ++ .../Layers/ProviderRuntimeIngestion.ts | 50 +++++++ apps/server/src/orchestration/Schemas.ts | 2 + .../orchestration/commandInvariants.test.ts | 2 + apps/server/src/orchestration/decider.ts | 24 ++++ apps/server/src/orchestration/projector.ts | 26 ++++ .../persistence/Layers/ProjectionProjects.ts | 5 + .../Layers/ProjectionRepositories.test.ts | 1 + .../Layers/ProjectionThreadSessions.ts | 4 + apps/server/src/persistence/Migrations.ts | 4 + ...16_ProjectionThreadSessionSlashCommands.ts | 11 ++ ...017_ProjectionProjectSlashCommandsCache.ts | 11 ++ .../Services/ProjectionProjects.ts | 1 + .../Services/ProjectionThreadSessions.ts | 1 + .../src/provider/Layers/ClaudeAdapter.test.ts | 2 + .../src/provider/Layers/ClaudeAdapter.ts | 56 ++++++++ .../src/provider/Layers/CodexAdapter.ts | 1 + .../Layers/ProviderAdapterRegistry.test.ts | 2 + .../provider/Layers/ProviderService.test.ts | 1 + .../src/provider/Layers/ProviderService.ts | 7 + .../src/provider/Services/ProviderAdapter.ts | 11 ++ .../src/provider/Services/ProviderService.ts | 9 ++ apps/server/src/wsServer.test.ts | 1 + apps/web/src/components/ChatView.browser.tsx | 3 + apps/web/src/components/ChatView.tsx | 129 ++++++++++++++++-- .../components/KeybindingsToast.browser.tsx | 2 + apps/web/src/components/Sidebar.logic.test.ts | 2 + .../components/chat/ComposerCommandMenu.tsx | 37 ++++- apps/web/src/composer-logic.ts | 13 +- apps/web/src/store.test.ts | 9 ++ apps/web/src/store.ts | 2 + apps/web/src/types.ts | 3 + packages/contracts/src/orchestration.ts | 36 +++++ 43 files changed, 600 insertions(+), 30 deletions(-) create mode 100644 apps/server/src/persistence/Migrations/016_ProjectionThreadSessionSlashCommands.ts create mode 100644 apps/server/src/persistence/Migrations/017_ProjectionProjectSlashCommandsCache.ts diff --git a/apps/server/integration/TestProviderAdapter.integration.ts b/apps/server/integration/TestProviderAdapter.integration.ts index 9c87d9821a..c4cd88fb18 100644 --- a/apps/server/integration/TestProviderAdapter.integration.ts +++ b/apps/server/integration/TestProviderAdapter.integration.ts @@ -488,6 +488,7 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter readThread, rollbackThread, stopAll, + discoverSlashCommands: () => Effect.succeed([]), streamEvents: Stream.fromQueue(runtimeEvents), }; diff --git a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts index 9fb2500ce4..ced5b1d60a 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts @@ -33,6 +33,7 @@ function makeSnapshot(input: { workspaceRoot: input.workspaceRoot, defaultModelSelection: null, scripts: [], + cachedProviderSlashCommands: {}, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", deletedAt: null, diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 075f62f889..6539ca2658 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -93,6 +93,7 @@ function createProviderServiceHarness( listSessions, getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }), rollbackConversation, + discoverSlashCommands: () => Effect.succeed([]), streamEvents: Stream.fromPubSub(runtimeEventPubSub), }; @@ -355,6 +356,7 @@ describe("CheckpointReactor", () => { runtimeMode: "approval-required", activeTurnId: null, lastError: null, + providerSlashCommands: [], updatedAt: createdAt, }, createdAt, @@ -431,6 +433,7 @@ describe("CheckpointReactor", () => { runtimeMode: "approval-required", activeTurnId: asTurnId("turn-main"), lastError: null, + providerSlashCommands: [], updatedAt: createdAt, }, createdAt, @@ -508,6 +511,7 @@ describe("CheckpointReactor", () => { runtimeMode: "approval-required", activeTurnId: null, lastError: null, + providerSlashCommands: [], updatedAt: createdAt, }, createdAt, @@ -566,6 +570,7 @@ describe("CheckpointReactor", () => { runtimeMode: "approval-required", activeTurnId: null, lastError: null, + providerSlashCommands: [], updatedAt: createdAt, }, createdAt, @@ -654,6 +659,7 @@ describe("CheckpointReactor", () => { runtimeMode: "approval-required", activeTurnId: asTurnId("turn-missing-cwd"), lastError: null, + providerSlashCommands: [], updatedAt: createdAt, }, createdAt, @@ -701,6 +707,7 @@ describe("CheckpointReactor", () => { runtimeMode: "approval-required", activeTurnId: null, lastError: null, + providerSlashCommands: [], updatedAt: createdAt, }, createdAt, @@ -751,6 +758,7 @@ describe("CheckpointReactor", () => { runtimeMode: "approval-required", activeTurnId: null, lastError: null, + providerSlashCommands: [], updatedAt: createdAt, }, createdAt, @@ -803,6 +811,7 @@ describe("CheckpointReactor", () => { runtimeMode: "approval-required", activeTurnId: null, lastError: null, + providerSlashCommands: [], updatedAt: createdAt, }, createdAt, @@ -881,6 +890,7 @@ describe("CheckpointReactor", () => { runtimeMode: "approval-required", activeTurnId: null, lastError: null, + providerSlashCommands: [], updatedAt: createdAt, }, createdAt, @@ -950,6 +960,7 @@ describe("CheckpointReactor", () => { runtimeMode: "approval-required", activeTurnId: null, lastError: null, + providerSlashCommands: [], updatedAt: createdAt, }, createdAt, diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts index 69b28b9d3c..7d941cfccc 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts @@ -37,6 +37,7 @@ function commandToAggregateRef(command: OrchestrationCommand): { case "project.create": case "project.meta.update": case "project.delete": + case "project.provider-slash-commands.set": return { aggregateKind: "project", aggregateId: command.projectId, diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 77b5d4d619..85e70bec86 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -1784,6 +1784,7 @@ it.effect("restores pending turn-start metadata across projection pipeline resta runtimeMode: "approval-required", activeTurnId: turnId, lastError: null, + providerSlashCommands: [], updatedAt: sessionSetAt, }, }, diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index f0f2ccabee..096d4438b5 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -385,6 +385,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti workspaceRoot: event.payload.workspaceRoot, defaultModelSelection: event.payload.defaultModelSelection, scripts: event.payload.scripts, + cachedProviderSlashCommandsJson: null, createdAt: event.payload.createdAt, updatedAt: event.payload.updatedAt, deletedAt: null, @@ -428,6 +429,26 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti return; } + case "project.provider-slash-commands-set": { + const existingRow = yield* projectionProjectRepository.getById({ + projectId: event.payload.projectId, + }); + if (Option.isNone(existingRow)) { + return; + } + // Merge new provider commands into existing cached map. + const existingCache: Record = existingRow.value.cachedProviderSlashCommandsJson + ? JSON.parse(existingRow.value.cachedProviderSlashCommandsJson) + : {}; + existingCache[event.payload.provider] = event.payload.commands; + yield* projectionProjectRepository.upsert({ + ...existingRow.value, + cachedProviderSlashCommandsJson: JSON.stringify(existingCache), + updatedAt: event.payload.updatedAt, + }); + return; + } + default: return; } @@ -803,6 +824,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti if (event.type !== "thread.session-set") { return; } + const providerSlashCommands = event.payload.session.providerSlashCommands; yield* projectionThreadSessionRepository.upsert({ threadId: event.payload.threadId, status: event.payload.session.status, @@ -810,6 +832,10 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti runtimeMode: event.payload.session.runtimeMode, activeTurnId: event.payload.session.activeTurnId, lastError: event.payload.session.lastError, + providerSlashCommandsJson: + providerSlashCommands && providerSlashCommands.length > 0 + ? JSON.stringify(providerSlashCommands) + : null, updatedAt: event.payload.session.updatedAt, }); }); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 32143d751f..7fdb7ea4ba 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -247,6 +247,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { runOnWorktreeCreate: false, }, ], + cachedProviderSlashCommands: {}, createdAt: "2026-02-24T00:00:00.000Z", updatedAt: "2026-02-24T00:00:01.000Z", deletedAt: null, @@ -332,6 +333,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { runtimeMode: "approval-required", activeTurnId: asTurnId("turn-1"), lastError: null, + providerSlashCommands: [], updatedAt: "2026-02-24T00:00:07.000Z", }, }, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index f951c54b5b..a2bdae1817 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -68,7 +68,11 @@ const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( sequence: Schema.NullOr(NonNegativeInt), }), ); -const ProjectionThreadSessionDbRowSchema = ProjectionThreadSession; +const ProjectionThreadSessionDbRowSchema = ProjectionThreadSession.mapFields( + Struct.assign({ + providerSlashCommandsJson: Schema.NullOr(Schema.String), + }), +); const ProjectionCheckpointDbRowSchema = ProjectionCheckpoint.mapFields( Struct.assign({ files: Schema.fromJsonString(Schema.Array(OrchestrationCheckpointFile)), @@ -149,6 +153,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { workspace_root AS "workspaceRoot", default_model_selection_json AS "defaultModelSelection", scripts_json AS "scripts", + cached_provider_slash_commands_json AS "cachedProviderSlashCommandsJson", created_at AS "createdAt", updated_at AS "updatedAt", deleted_at AS "deletedAt" @@ -259,6 +264,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { runtime_mode AS "runtimeMode", active_turn_id AS "activeTurnId", last_error AS "lastError", + provider_slash_commands_json AS "providerSlashCommandsJson", updated_at AS "updatedAt" FROM projection_thread_sessions ORDER BY thread_id ASC @@ -527,6 +533,16 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { for (const row of sessionRows) { updatedAt = maxIso(updatedAt, row.updatedAt); + const providerSlashCommands: Array = (() => { + const json = row.providerSlashCommandsJson; + if (!json) return []; + try { + const parsed: unknown = JSON.parse(json); + return Array.isArray(parsed) ? (parsed as Array) : []; + } catch { + return []; + } + })(); sessionsByThread.set(row.threadId, { threadId: row.threadId, status: row.status, @@ -534,20 +550,33 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { runtimeMode: row.runtimeMode, activeTurnId: row.activeTurnId, lastError: row.lastError, + providerSlashCommands, updatedAt: row.updatedAt, }); } - const projects: ReadonlyArray = projectRows.map((row) => ({ - id: row.projectId, - title: row.title, - workspaceRoot: row.workspaceRoot, - defaultModelSelection: row.defaultModelSelection, - scripts: row.scripts, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - deletedAt: row.deletedAt, - })); + const projects: ReadonlyArray = projectRows.map((row) => { + const cachedProviderSlashCommands: OrchestrationProject["cachedProviderSlashCommands"] = + (() => { + if (!row.cachedProviderSlashCommandsJson) return {}; + try { + return JSON.parse(row.cachedProviderSlashCommandsJson) as OrchestrationProject["cachedProviderSlashCommands"]; + } catch { + return {}; + } + })(); + return { + id: row.projectId, + title: row.title, + workspaceRoot: row.workspaceRoot, + defaultModelSelection: row.defaultModelSelection, + scripts: row.scripts, + cachedProviderSlashCommands, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + deletedAt: row.deletedAt, + }; + }); const threads: ReadonlyArray = threadRows.map((row) => ({ id: row.threadId, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index ed7037695f..d610f60ad3 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -207,6 +207,7 @@ describe("ProviderCommandReactor", () => { sessionModelSwitch: input?.sessionModelSwitch ?? "in-session", }), rollbackConversation: () => unsupported(), + discoverSlashCommands: () => Effect.succeed([]), streamEvents: Stream.fromPubSub(runtimeEventPubSub), }; @@ -1038,6 +1039,7 @@ describe("ProviderCommandReactor", () => { runtimeMode: "full-access", activeTurnId: null, lastError: null, + providerSlashCommands: [], updatedAt: now, }, createdAt: now, @@ -1222,6 +1224,7 @@ describe("ProviderCommandReactor", () => { runtimeMode: "approval-required", activeTurnId: asTurnId("turn-1"), lastError: null, + providerSlashCommands: [], updatedAt: now, }, createdAt: now, @@ -1260,6 +1263,7 @@ describe("ProviderCommandReactor", () => { runtimeMode: "approval-required", activeTurnId: null, lastError: null, + providerSlashCommands: [], updatedAt: now, }, createdAt: now, @@ -1301,6 +1305,7 @@ describe("ProviderCommandReactor", () => { runtimeMode: "approval-required", activeTurnId: null, lastError: null, + providerSlashCommands: [], updatedAt: now, }, createdAt: now, @@ -1355,6 +1360,7 @@ describe("ProviderCommandReactor", () => { runtimeMode: "approval-required", activeTurnId: null, lastError: null, + providerSlashCommands: [], updatedAt: now, }, createdAt: now, @@ -1452,6 +1458,7 @@ describe("ProviderCommandReactor", () => { runtimeMode: "approval-required", activeTurnId: null, lastError: null, + providerSlashCommands: [], updatedAt: now, }, createdAt: now, @@ -1554,6 +1561,7 @@ describe("ProviderCommandReactor", () => { runtimeMode: "approval-required", activeTurnId: null, lastError: null, + providerSlashCommands: [], updatedAt: now, }, createdAt: now, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index d4f13ec727..14b5aaf9e0 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -4,8 +4,10 @@ import { EventId, type ModelSelection, type OrchestrationEvent, + type ProviderSlashCommandInfo, ProviderKind, type OrchestrationSession, + ProjectCreatedPayload, ThreadId, type ProviderSession, type RuntimeMode, @@ -284,6 +286,7 @@ const make = Effect.gen(function* () { // Provider turn ids are not orchestration turn ids. activeTurnId: null, lastError: session.lastError ?? null, + providerSlashCommands: [], updatedAt: session.updatedAt, }, createdAt, @@ -718,6 +721,7 @@ const make = Effect.gen(function* () { runtimeMode: thread.session?.runtimeMode ?? DEFAULT_RUNTIME_MODE, activeTurnId: null, lastError: thread.session?.lastError ?? null, + providerSlashCommands: thread.session?.providerSlashCommands ?? [], updatedAt: now, }, createdAt: now, @@ -773,6 +777,53 @@ const make = Effect.gen(function* () { const worker = yield* makeDrainableWorker(processDomainEventSafely); + // Discover provider slash commands for a project and cache them. + const discoverAndCacheSlashCommands = (projectId: string, workspaceRoot: string) => + Effect.gen(function* () { + const providers = ["claudeAgent", "codex"] as const; + for (const provider of providers) { + const commands: ReadonlyArray = + yield* providerService.discoverSlashCommands({ + provider, + cwd: workspaceRoot, + }); + if (commands.length > 0) { + yield* orchestrationEngine.dispatch({ + type: "project.provider-slash-commands.set", + commandId: serverCommandId("slash-command-discovery"), + projectId: projectId as any, + provider, + commands: [...commands], + createdAt: new Date().toISOString(), + }); + } + } + }).pipe( + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.failCause(cause); + } + return Effect.logWarning( + "provider command reactor failed to discover slash commands on project creation", + { projectId, cause: Cause.pretty(cause) }, + ); + }), + ); + + // Background listener for project.created events to trigger slash command discovery. + const projectCreatedWorker = Stream.runForEach( + orchestrationEngine.streamDomainEvents, + (event) => { + if (event.type !== "project.created") { + return Effect.void; + } + const payload = Schema.decodeUnknownSync(ProjectCreatedPayload)(event.payload); + return Effect.forkScoped( + discoverAndCacheSlashCommands(payload.projectId, payload.workspaceRoot), + ).pipe(Effect.asVoid); + }, + ); + const start: ProviderCommandReactorShape["start"] = Effect.fn("start")(function* () { const processEvent = Effect.fn("processEvent")(function* (event: OrchestrationEvent) { if ( @@ -790,6 +841,7 @@ const make = Effect.gen(function* () { yield* Effect.forkScoped( Stream.runForEach(orchestrationEngine.streamDomainEvents, processEvent), ); + yield* Effect.forkScoped(projectCreatedWorker); }); return { diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 529eae2444..1020ac545b 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -97,6 +97,7 @@ function createProviderServiceHarness() { listSessions: () => Effect.succeed([...runtimeSessions]), getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }), rollbackConversation: () => unsupported(), + discoverSlashCommands: () => Effect.succeed([]), streamEvents: Stream.fromPubSub(runtimeEventPubSub), }; @@ -263,6 +264,7 @@ describe("ProviderRuntimeIngestion", () => { activeTurnId: null, updatedAt: createdAt, lastError: null, + providerSlashCommands: [], }, createdAt, }), @@ -490,6 +492,7 @@ describe("ProviderRuntimeIngestion", () => { activeTurnId: null, updatedAt: seededAt, lastError: null, + providerSlashCommands: [], }, createdAt: seededAt, }), @@ -792,6 +795,7 @@ describe("ProviderRuntimeIngestion", () => { activeTurnId: null, updatedAt: createdAt, lastError: null, + providerSlashCommands: [], }, createdAt, }), @@ -827,6 +831,7 @@ describe("ProviderRuntimeIngestion", () => { activeTurnId: null, updatedAt: createdAt, lastError: null, + providerSlashCommands: [], }, createdAt, }), @@ -979,6 +984,7 @@ describe("ProviderRuntimeIngestion", () => { activeTurnId: null, updatedAt: createdAt, lastError: null, + providerSlashCommands: [], }, createdAt, }), @@ -1132,6 +1138,7 @@ describe("ProviderRuntimeIngestion", () => { activeTurnId: null, updatedAt: createdAt, lastError: null, + providerSlashCommands: [], }, createdAt, }), @@ -1167,6 +1174,7 @@ describe("ProviderRuntimeIngestion", () => { activeTurnId: null, updatedAt: createdAt, lastError: null, + providerSlashCommands: [], }, createdAt, }), diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index b479d1c28a..589766ff7d 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -985,6 +985,7 @@ const make = Effect.fn("make")(function* () { runtimeMode: thread.session?.runtimeMode ?? "full-access", activeTurnId: nextActiveTurnId, lastError, + providerSlashCommands: thread.session?.providerSlashCommands ?? [], updatedAt: now, }, createdAt: now, @@ -1008,6 +1009,54 @@ const make = Effect.fn("make")(function* () { yield* rememberAssistantMessageId(thread.id, turnId, assistantMessageId); } + // Handle session.configured to extract provider slash commands from the init message. + if (event.type === "session.configured") { + const config = event.payload?.config as Record | undefined; + const rawSlashCommands = config?.slash_commands; + const providerSlashCommands = Array.isArray(rawSlashCommands) + ? (rawSlashCommands.filter((cmd): cmd is string => typeof cmd === "string")) + : []; + if (providerSlashCommands.length > 0) { + yield* orchestrationEngine.dispatch({ + type: "thread.session.set", + commandId: providerCommandId(event, "thread-session-configured"), + threadId: thread.id, + session: { + threadId: thread.id, + status: thread.session?.status ?? "ready", + providerName: thread.session?.providerName ?? event.provider, + runtimeMode: thread.session?.runtimeMode ?? "full-access", + activeTurnId: thread.session?.activeTurnId ?? null, + lastError: thread.session?.lastError ?? null, + providerSlashCommands, + updatedAt: now, + }, + createdAt: now, + }); + + // Also update the project-level cached slash commands. + yield* orchestrationEngine.dispatch({ + type: "project.provider-slash-commands.set", + commandId: providerCommandId(event, "project-slash-commands-cache"), + projectId: thread.projectId as any, + provider: event.provider, + commands: providerSlashCommands.map((name) => ({ + name, + description: "", + argumentHint: "", + })), + createdAt: now, + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning( + "provider runtime ingestion failed to update project slash commands cache", + { cause: Cause.pretty(cause) }, + ), + ), + ); + } + } + const assistantDeliveryMode: AssistantDeliveryMode = yield* Effect.map( serverSettingsService.getSettings, (settings) => (settings.enableAssistantStreaming ? "streaming" : "buffered"), @@ -1157,6 +1206,7 @@ const make = Effect.fn("make")(function* () { runtimeMode: thread.session?.runtimeMode ?? "full-access", activeTurnId: eventTurnId ?? null, lastError: runtimeErrorMessage, + providerSlashCommands: thread.session?.providerSlashCommands ?? [], updatedAt: now, }, createdAt: now, diff --git a/apps/server/src/orchestration/Schemas.ts b/apps/server/src/orchestration/Schemas.ts index f7ebf69344..1d59077691 100644 --- a/apps/server/src/orchestration/Schemas.ts +++ b/apps/server/src/orchestration/Schemas.ts @@ -2,6 +2,7 @@ import { ProjectCreatedPayload as ContractsProjectCreatedPayloadSchema, ProjectMetaUpdatedPayload as ContractsProjectMetaUpdatedPayloadSchema, ProjectDeletedPayload as ContractsProjectDeletedPayloadSchema, + ProjectProviderSlashCommandsSetPayload as ContractsProjectProviderSlashCommandsSetPayloadSchema, ThreadCreatedPayload as ContractsThreadCreatedPayloadSchema, ThreadArchivedPayload as ContractsThreadArchivedPayloadSchema, ThreadMetaUpdatedPayload as ContractsThreadMetaUpdatedPayloadSchema, @@ -26,6 +27,7 @@ import { export const ProjectCreatedPayload = ContractsProjectCreatedPayloadSchema; export const ProjectMetaUpdatedPayload = ContractsProjectMetaUpdatedPayloadSchema; export const ProjectDeletedPayload = ContractsProjectDeletedPayloadSchema; +export const ProjectProviderSlashCommandsSetPayload = ContractsProjectProviderSlashCommandsSetPayloadSchema; export const ThreadCreatedPayload = ContractsThreadCreatedPayloadSchema; export const ThreadArchivedPayload = ContractsThreadArchivedPayloadSchema; diff --git a/apps/server/src/orchestration/commandInvariants.test.ts b/apps/server/src/orchestration/commandInvariants.test.ts index 43d665a2c9..307b976f32 100644 --- a/apps/server/src/orchestration/commandInvariants.test.ts +++ b/apps/server/src/orchestration/commandInvariants.test.ts @@ -33,6 +33,7 @@ const readModel: OrchestrationReadModel = { model: "gpt-5-codex", }, scripts: [], + cachedProviderSlashCommands: {}, createdAt: now, updatedAt: now, deletedAt: null, @@ -46,6 +47,7 @@ const readModel: OrchestrationReadModel = { model: "gpt-5-codex", }, scripts: [], + cachedProviderSlashCommands: {}, createdAt: now, updatedAt: now, deletedAt: null, diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 22f5bcb280..f50346e25c 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -677,6 +677,30 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "project.provider-slash-commands.set": { + yield* requireProject({ + readModel, + command, + projectId: command.projectId, + }); + const occurredAt = command.createdAt; + return { + ...withEventBase({ + aggregateKind: "project", + aggregateId: command.projectId, + occurredAt, + commandId: command.commandId, + }), + type: "project.provider-slash-commands-set", + payload: { + projectId: command.projectId, + provider: command.provider, + commands: command.commands, + updatedAt: occurredAt, + }, + }; + } + default: { command satisfies never; const fallback = command as never as { type: string }; diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index deb8a6d44d..ba112acb46 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -13,6 +13,7 @@ import { ProjectCreatedPayload, ProjectDeletedPayload, ProjectMetaUpdatedPayload, + ProjectProviderSlashCommandsSetPayload, ThreadActivityAppendedPayload, ThreadArchivedPayload, ThreadCreatedPayload, @@ -185,6 +186,7 @@ export function projectEvent( workspaceRoot: payload.workspaceRoot, defaultModelSelection: payload.defaultModelSelection, scripts: payload.scripts, + cachedProviderSlashCommands: {}, createdAt: payload.createdAt, updatedAt: payload.updatedAt, deletedAt: null, @@ -648,6 +650,30 @@ export function projectEvent( }), ); + case "project.provider-slash-commands-set": + return decodeForEvent( + ProjectProviderSlashCommandsSetPayload, + event.payload, + event.type, + "payload", + ).pipe( + Effect.map((payload) => ({ + ...nextBase, + projects: nextBase.projects.map((project) => + project.id === payload.projectId + ? { + ...project, + cachedProviderSlashCommands: { + ...project.cachedProviderSlashCommands, + [payload.provider]: payload.commands, + }, + updatedAt: payload.updatedAt, + } + : project, + ), + })), + ); + default: return Effect.succeed(nextBase); } diff --git a/apps/server/src/persistence/Layers/ProjectionProjects.ts b/apps/server/src/persistence/Layers/ProjectionProjects.ts index 7ff19f55ae..5458834653 100644 --- a/apps/server/src/persistence/Layers/ProjectionProjects.ts +++ b/apps/server/src/persistence/Layers/ProjectionProjects.ts @@ -33,6 +33,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { workspace_root, default_model_selection_json, scripts_json, + cached_provider_slash_commands_json, created_at, updated_at, deleted_at @@ -43,6 +44,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { ${row.workspaceRoot}, ${row.defaultModelSelection !== null ? JSON.stringify(row.defaultModelSelection) : null}, ${JSON.stringify(row.scripts)}, + ${row.cachedProviderSlashCommandsJson}, ${row.createdAt}, ${row.updatedAt}, ${row.deletedAt} @@ -53,6 +55,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { workspace_root = excluded.workspace_root, default_model_selection_json = excluded.default_model_selection_json, scripts_json = excluded.scripts_json, + cached_provider_slash_commands_json = excluded.cached_provider_slash_commands_json, created_at = excluded.created_at, updated_at = excluded.updated_at, deleted_at = excluded.deleted_at @@ -70,6 +73,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { workspace_root AS "workspaceRoot", default_model_selection_json AS "defaultModelSelection", scripts_json AS "scripts", + cached_provider_slash_commands_json AS "cachedProviderSlashCommandsJson", created_at AS "createdAt", updated_at AS "updatedAt", deleted_at AS "deletedAt" @@ -89,6 +93,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { workspace_root AS "workspaceRoot", default_model_selection_json AS "defaultModelSelection", scripts_json AS "scripts", + cached_provider_slash_commands_json AS "cachedProviderSlashCommandsJson", created_at AS "createdAt", updated_at AS "updatedAt", deleted_at AS "deletedAt" diff --git a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts index b0e1774837..5236720bca 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -32,6 +32,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { model: "gpt-5.4", }, scripts: [], + cachedProviderSlashCommandsJson: null, createdAt: "2026-03-24T00:00:00.000Z", updatedAt: "2026-03-24T00:00:00.000Z", deletedAt: null, diff --git a/apps/server/src/persistence/Layers/ProjectionThreadSessions.ts b/apps/server/src/persistence/Layers/ProjectionThreadSessions.ts index 2499eba196..d7868a184a 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadSessions.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadSessions.ts @@ -26,6 +26,7 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () { runtime_mode, active_turn_id, last_error, + provider_slash_commands_json, updated_at ) VALUES ( @@ -35,6 +36,7 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () { ${row.runtimeMode}, ${row.activeTurnId}, ${row.lastError}, + ${row.providerSlashCommandsJson}, ${row.updatedAt} ) ON CONFLICT (thread_id) @@ -44,6 +46,7 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () { runtime_mode = excluded.runtime_mode, active_turn_id = excluded.active_turn_id, last_error = excluded.last_error, + provider_slash_commands_json = excluded.provider_slash_commands_json, updated_at = excluded.updated_at `, }); @@ -60,6 +63,7 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () { runtime_mode AS "runtimeMode", active_turn_id AS "activeTurnId", last_error AS "lastError", + provider_slash_commands_json AS "providerSlashCommandsJson", updated_at AS "updatedAt" FROM projection_thread_sessions WHERE thread_id = ${threadId} diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index c759665f06..ad1f36b8a4 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -31,6 +31,8 @@ import Migration0015 from "./Migrations/015_ProjectionTurnsSourceProposedPlan.ts import Migration0016 from "./Migrations/016_CanonicalizeModelSelections.ts"; import Migration0017 from "./Migrations/017_ProjectionThreadsArchivedAt.ts"; import Migration0018 from "./Migrations/018_ProjectionThreadsArchivedAtIndex.ts"; +import Migration0019 from "./Migrations/016_ProjectionThreadSessionSlashCommands.ts"; +import Migration0020 from "./Migrations/017_ProjectionProjectSlashCommandsCache.ts"; /** * Migration loader with all migrations defined inline. @@ -61,6 +63,8 @@ export const migrationEntries = [ [16, "CanonicalizeModelSelections", Migration0016], [17, "ProjectionThreadsArchivedAt", Migration0017], [18, "ProjectionThreadsArchivedAtIndex", Migration0018], + [19, "ProjectionThreadSessionSlashCommands", Migration0019], + [20, "ProjectionProjectSlashCommandsCache", Migration0020], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/016_ProjectionThreadSessionSlashCommands.ts b/apps/server/src/persistence/Migrations/016_ProjectionThreadSessionSlashCommands.ts new file mode 100644 index 0000000000..d60c261d0c --- /dev/null +++ b/apps/server/src/persistence/Migrations/016_ProjectionThreadSessionSlashCommands.ts @@ -0,0 +1,11 @@ +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as Effect from "effect/Effect"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + ALTER TABLE projection_thread_sessions + ADD COLUMN provider_slash_commands_json TEXT DEFAULT NULL + `; +}); diff --git a/apps/server/src/persistence/Migrations/017_ProjectionProjectSlashCommandsCache.ts b/apps/server/src/persistence/Migrations/017_ProjectionProjectSlashCommandsCache.ts new file mode 100644 index 0000000000..cc318266ae --- /dev/null +++ b/apps/server/src/persistence/Migrations/017_ProjectionProjectSlashCommandsCache.ts @@ -0,0 +1,11 @@ +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as Effect from "effect/Effect"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + ALTER TABLE projection_projects + ADD COLUMN cached_provider_slash_commands_json TEXT DEFAULT NULL + `; +}); diff --git a/apps/server/src/persistence/Services/ProjectionProjects.ts b/apps/server/src/persistence/Services/ProjectionProjects.ts index 996ffe6e7b..59b8a2d659 100644 --- a/apps/server/src/persistence/Services/ProjectionProjects.ts +++ b/apps/server/src/persistence/Services/ProjectionProjects.ts @@ -18,6 +18,7 @@ export const ProjectionProject = Schema.Struct({ workspaceRoot: Schema.String, defaultModelSelection: Schema.NullOr(ModelSelection), scripts: Schema.Array(ProjectScript), + cachedProviderSlashCommandsJson: Schema.NullOr(Schema.String), createdAt: IsoDateTime, updatedAt: IsoDateTime, deletedAt: Schema.NullOr(IsoDateTime), diff --git a/apps/server/src/persistence/Services/ProjectionThreadSessions.ts b/apps/server/src/persistence/Services/ProjectionThreadSessions.ts index 537ee10bee..f1b634f288 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadSessions.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadSessions.ts @@ -25,6 +25,7 @@ export const ProjectionThreadSession = Schema.Struct({ runtimeMode: RuntimeMode, activeTurnId: Schema.NullOr(TurnId), lastError: Schema.NullOr(Schema.String), + providerSlashCommandsJson: Schema.NullOr(Schema.String), updatedAt: IsoDateTime, }); export type ProjectionThreadSession = typeof ProjectionThreadSession.Type; diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index d064a8239f..9cf6f9369c 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -91,6 +91,8 @@ class FakeClaudeQuery implements AsyncIterable { this.setMaxThinkingTokensCalls.push(maxThinkingTokens); }; + readonly supportedCommands = () => Promise.resolve([]); + readonly close = (): void => { this.closeCalls += 1; this.finish(); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 6b50bd4fbb..7eb64d8702 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -50,6 +50,7 @@ import { Cause, DateTime, Deferred, + Duration, Effect, Exit, FileSystem, @@ -170,6 +171,7 @@ interface ClaudeQueryRuntime extends AsyncIterable { readonly setModel: (model?: string) => Promise; readonly setPermissionMode: (mode: PermissionMode) => Promise; readonly setMaxThinkingTokens: (maxThinkingTokens: number | null) => Promise; + readonly supportedCommands: () => Promise>; readonly close: () => void; } @@ -3038,6 +3040,59 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ).pipe(Effect.tap(() => Queue.shutdown(runtimeEventQueue))), ); + const discoverSlashCommands: ClaudeAdapterShape["discoverSlashCommands"] = (input) => { + async function* helloPrompt(): AsyncGenerator { + yield { + type: "user", + session_id: "", + parent_tool_use_id: null, + message: { role: "user", content: [{ type: "text", text: "hello" }] }, + } as SDKUserMessage; + } + + return Effect.tryPromise({ + try: async () => { + const probeQuery = createQuery({ + prompt: helloPrompt(), + options: { + cwd: input.cwd, + pathToClaudeCodeExecutable: "claude", + settingSources: [...CLAUDE_SETTING_SOURCES], + permissionMode: "plan", + env: process.env, + }, + }); + + try { + const commands = await probeQuery.supportedCommands(); + return commands.map((cmd) => ({ + name: cmd.name, + description: cmd.description, + argumentHint: cmd.argumentHint, + })); + } finally { + probeQuery.close(); + } + }, + catch: (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: "" as any, + detail: `Failed to discover slash commands: ${String(cause)}`, + cause, + }), + }).pipe( + Effect.timeout(Duration.seconds(15)), + Effect.map((option) => option ?? []), + Effect.catchCause((cause: Cause.Cause) => + Effect.logWarning("ClaudeAdapter.discoverSlashCommands failed", { + cwd: input.cwd, + cause: Cause.pretty(cause), + }).pipe(Effect.as([] as ReadonlyArray<{ name: string; description: string; argumentHint: string }>)), + ), + ); + }; + return { provider: PROVIDER, capabilities: { @@ -3054,6 +3109,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( listSessions, hasSession, stopAll, + discoverSlashCommands, streamEvents: Stream.fromQueue(runtimeEventQueue), } satisfies ClaudeAdapterShape; }); diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index b9ac4bfc4a..d428f54932 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -1612,6 +1612,7 @@ const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( listSessions, hasSession, stopAll, + discoverSlashCommands: () => Effect.succeed([]), streamEvents: Stream.fromQueue(runtimeEventQueue), } satisfies CodexAdapterShape; }); diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts index db0293f0fe..950dec8d01 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts @@ -25,6 +25,7 @@ const fakeCodexAdapter: CodexAdapterShape = { readThread: vi.fn(), rollbackThread: vi.fn(), stopAll: vi.fn(), + discoverSlashCommands: vi.fn(), streamEvents: Stream.empty, }; @@ -42,6 +43,7 @@ const fakeClaudeAdapter: ClaudeAdapterShape = { readThread: vi.fn(), rollbackThread: vi.fn(), stopAll: vi.fn(), + discoverSlashCommands: vi.fn(), streamEvents: Stream.empty, }; diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 651a611649..7ec6d07ae7 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -191,6 +191,7 @@ function makeFakeCodexAdapter(provider: ProviderKind = "codex") { readThread, rollbackThread, stopAll, + discoverSlashCommands: () => Effect.succeed([]), streamEvents: Stream.fromPubSub(runtimeEventPubSub), }; diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 0137152e83..e0c2eed57d 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -578,6 +578,12 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => ), ); + const discoverSlashCommands: ProviderServiceShape["discoverSlashCommands"] = (input) => + Effect.gen(function* () { + const adapter = yield* registry.getByProvider(input.provider); + return yield* adapter.discoverSlashCommands({ cwd: input.cwd }); + }); + return { startSession, sendTurn, @@ -588,6 +594,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => listSessions, getCapabilities, rollbackConversation, + discoverSlashCommands, // Each access creates a fresh PubSub subscription so that multiple // consumers (ProviderRuntimeIngestion, CheckpointReactor, etc.) each // independently receive all runtime events. diff --git a/apps/server/src/provider/Services/ProviderAdapter.ts b/apps/server/src/provider/Services/ProviderAdapter.ts index 38a05f7574..385f9c56c2 100644 --- a/apps/server/src/provider/Services/ProviderAdapter.ts +++ b/apps/server/src/provider/Services/ProviderAdapter.ts @@ -11,6 +11,7 @@ import type { ApprovalRequestId, ProviderApprovalDecision, ProviderKind, + ProviderSlashCommandInfo, ProviderUserInputAnswers, ProviderRuntimeEvent, ProviderSendTurnInput, @@ -119,6 +120,16 @@ export interface ProviderAdapterShape { */ readonly stopAll: () => Effect.Effect; + /** + * Discover available slash commands without a full session. + * + * Fires a lightweight probe query and returns the provider's available + * slash commands with rich metadata (name, description, argumentHint). + */ + readonly discoverSlashCommands: (input: { + readonly cwd: string; + }) => Effect.Effect, TError>; + /** * Canonical runtime event stream emitted by this adapter. */ diff --git a/apps/server/src/provider/Services/ProviderService.ts b/apps/server/src/provider/Services/ProviderService.ts index ebfe8c8ab1..d473c9f739 100644 --- a/apps/server/src/provider/Services/ProviderService.ts +++ b/apps/server/src/provider/Services/ProviderService.ts @@ -20,6 +20,7 @@ import type { ProviderSendTurnInput, ProviderSession, ProviderSessionStartInput, + ProviderSlashCommandInfo, ProviderStopSessionInput, ThreadId, ProviderTurnStartResult, @@ -99,6 +100,14 @@ export interface ProviderServiceShape { readonly numTurns: number; }) => Effect.Effect; + /** + * Discover available slash commands for a provider without a full session. + */ + readonly discoverSlashCommands: (input: { + readonly provider: ProviderKind; + readonly cwd: string; + }) => Effect.Effect, ProviderServiceError>; + /** * Canonical provider runtime event stream. * diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index 0e06650fe6..3f94939406 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -1283,6 +1283,7 @@ describe("WebSocket Server", () => { listSessions: () => Effect.succeed([]), getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }), rollbackConversation: () => unsupported(), + discoverSlashCommands: () => Effect.succeed([]), streamEvents: Stream.fromPubSub(runtimeEventPubSub), }; const providerLayer = Layer.succeed(ProviderService, providerService); diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 0d612534e5..c3241c0f03 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -237,6 +237,7 @@ function createSnapshotForTargetUser(options: { model: "gpt-5", }, scripts: [], + cachedProviderSlashCommands: {}, createdAt: NOW_ISO, updatedAt: NOW_ISO, deletedAt: null, @@ -271,6 +272,7 @@ function createSnapshotForTargetUser(options: { runtimeMode: "full-access", activeTurnId: null, lastError: null, + providerSlashCommands: [], updatedAt: NOW_ISO, }, }, @@ -329,6 +331,7 @@ function addThreadToSnapshot( runtimeMode: "full-access", activeTurnId: null, lastError: null, + providerSlashCommands: [], updatedAt: NOW_ISO, }, }, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index fbd332354a..f6551c260c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -189,6 +189,52 @@ const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; const EMPTY_AVAILABLE_EDITORS: EditorId[] = []; const EMPTY_PROVIDERS: ServerProvider[] = []; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; +const EMPTY_PROVIDER_SLASH_COMMANDS: string[] = []; + +/** + * Module-level cache of provider slash commands, keyed by provider kind. + * Once any session for a provider reports its slash commands, we cache them + * so that new threads with the same provider can show them immediately + * without waiting for the session to start. + * + * The cache is also persisted to localStorage so that commands survive + * page reloads. + */ +const PROVIDER_SLASH_COMMANDS_CACHE_KEY = "t3:providerSlashCommandsCache"; + +function loadProviderSlashCommandsCache(): Map { + const cache = new Map(); + try { + const raw = localStorage.getItem(PROVIDER_SLASH_COMMANDS_CACHE_KEY); + if (raw) { + const parsed: unknown = JSON.parse(raw); + if (parsed && typeof parsed === "object") { + for (const [key, value] of Object.entries(parsed as Record)) { + if (Array.isArray(value) && value.every((v) => typeof v === "string")) { + cache.set(key, value as string[]); + } + } + } + } + } catch { + // Ignore parse errors + } + return cache; +} + +function persistProviderSlashCommandsCache(cache: Map): void { + try { + const obj: Record = {}; + for (const [key, value] of cache) { + obj[key] = value; + } + localStorage.setItem(PROVIDER_SLASH_COMMANDS_CACHE_KEY, JSON.stringify(obj)); + } catch { + // Ignore storage errors + } +} + +const providerSlashCommandsCache = loadProviderSlashCommandsCache(); function formatOutgoingPrompt(params: { provider: ProviderKind; @@ -395,6 +441,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const activeComposerMenuItemRef = useRef(null); const attachmentPreviewHandoffByMessageIdRef = useRef>({}); const attachmentPreviewHandoffTimeoutByMessageIdRef = useRef>({}); + const providerSlashCommandsRef = useRef(EMPTY_PROVIDER_SLASH_COMMANDS); const sendInFlightRef = useRef(false); const dragDepthRef = useRef(0); const terminalOpenByThreadRef = useRef>({}); @@ -460,6 +507,7 @@ export default function ChatView({ threadId }: ChatViewProps) { detectComposerTrigger( nextPrompt.prompt, expandCollapsedComposerCursor(nextPrompt.prompt, nextPrompt.cursor), + providerSlashCommandsRef.current, ), ); }, @@ -632,6 +680,26 @@ export default function ChatView({ threadId }: ChatViewProps) { selectedProviderByThreadId ?? threadProvider ?? "codex", ); const selectedProvider: ProviderKind = lockedProvider ?? unlockedSelectedProvider; + + // Derive provider slash commands: use session commands if available, then project-level + // cached commands (populated on project creation), then localStorage cache as last fallback. + const sessionSlashCommands = activeThread?.session?.providerSlashCommands; + const projectCachedCommands = activeProject?.cachedProviderSlashCommands?.[selectedProvider]; + const providerSlashCommands = useMemo(() => { + if (sessionSlashCommands && sessionSlashCommands.length > 0) { + providerSlashCommandsCache.set(selectedProvider, sessionSlashCommands); + persistProviderSlashCommandsCache(providerSlashCommandsCache); + return sessionSlashCommands; + } + if (projectCachedCommands && projectCachedCommands.length > 0) { + const names = projectCachedCommands.map((cmd) => cmd.name); + providerSlashCommandsCache.set(selectedProvider, names); + persistProviderSlashCommandsCache(providerSlashCommandsCache); + return names; + } + return providerSlashCommandsCache.get(selectedProvider) ?? EMPTY_PROVIDER_SLASH_COMMANDS; + }, [sessionSlashCommands, projectCachedCommands, selectedProvider]); + providerSlashCommandsRef.current = providerSlashCommands; const { modelOptions: composerModelOptions, selectedModel } = useEffectiveComposerModelState({ threadId, providers: providerStatuses, @@ -791,6 +859,7 @@ export default function ChatView({ threadId }: ChatViewProps) { detectComposerTrigger( nextCustomAnswer, expandCollapsedComposerCursor(nextCustomAnswer, nextCursor), + providerSlashCommandsRef.current, ), ); setComposerHighlightedItemId(null); @@ -1081,7 +1150,7 @@ export default function ChatView({ threadId }: ChatViewProps) { } if (composerTrigger.kind === "slash-command") { - const slashCommandItems = [ + const slashCommandItems: ComposerCommandItem[] = [ { id: "slash:model", type: "slash-command", @@ -1103,13 +1172,29 @@ export default function ChatView({ threadId }: ChatViewProps) { label: "/default", description: "Switch this thread back to normal chat mode", }, - ] satisfies ReadonlyArray>; + ...providerSlashCommands.map((cmd) => { + const richInfo = projectCachedCommands?.find((c) => c.name === cmd); + return { + id: `provider-slash:${cmd}`, + type: "provider-slash-command" as const, + provider: selectedProvider, + command: cmd, + label: `/${cmd}`, + description: richInfo?.description || "Provider command", + }; + }), + ]; const query = composerTrigger.query.trim().toLowerCase(); if (!query) { - return [...slashCommandItems]; + return slashCommandItems; } return slashCommandItems.filter( - (item) => item.command.includes(query) || item.label.slice(1).includes(query), + (item) => { + const command = item.type === "slash-command" || item.type === "provider-slash-command" + ? item.command + : ""; + return command.includes(query) || item.label.slice(1).includes(query); + }, ); } @@ -1129,7 +1214,7 @@ export default function ChatView({ threadId }: ChatViewProps) { label: name, description: `${providerLabel} · ${slug}`, })); - }, [composerTrigger, searchableModelOptions, workspaceEntries]); + }, [composerTrigger, searchableModelOptions, workspaceEntries, providerSlashCommands, selectedProvider, projectCachedCommands]); const composerMenuOpen = Boolean(composerTrigger); const activeComposerMenuItem = useMemo( () => @@ -1290,7 +1375,7 @@ export default function ChatView({ threadId }: ChatViewProps) { } promptRef.current = insertion.prompt; setComposerCursor(nextCollapsedCursor); - setComposerTrigger(detectComposerTrigger(insertion.prompt, insertion.cursor)); + setComposerTrigger(detectComposerTrigger(insertion.prompt, insertion.cursor, providerSlashCommandsRef.current)); window.requestAnimationFrame(() => { composerEditorRef.current?.focusAt(nextCollapsedCursor); }); @@ -2024,7 +2109,7 @@ export default function ChatView({ threadId }: ChatViewProps) { setSendStartedAt(null); setComposerHighlightedItemId(null); setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length)); - setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length)); + setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length, providerSlashCommandsRef.current)); dragDepthRef.current = 0; setIsDragOverComposer(false); setExpandedImage(null); @@ -2763,7 +2848,7 @@ export default function ChatView({ threadId }: ChatViewProps) { setComposerCursor(collapseExpandedComposerCursor(promptForSend, promptForSend.length)); addComposerImagesToDraft(composerImagesSnapshot.map(cloneComposerImageForRetry)); addComposerTerminalContextsToDraft(composerTerminalContextsSnapshot); - setComposerTrigger(detectComposerTrigger(promptForSend, promptForSend.length)); + setComposerTrigger(detectComposerTrigger(promptForSend, promptForSend.length, providerSlashCommandsRef.current)); } setThreadError( threadIdForSend, @@ -2902,7 +2987,7 @@ export default function ChatView({ threadId }: ChatViewProps) { })); setComposerCursor(nextCursor); setComposerTrigger( - cursorAdjacentToMention ? null : detectComposerTrigger(value, expandedCursor), + cursorAdjacentToMention ? null : detectComposerTrigger(value, expandedCursor, providerSlashCommandsRef.current), ); }, [activePendingUserInput], @@ -3226,7 +3311,7 @@ export default function ChatView({ threadId }: ChatViewProps) { setPrompt(nextPrompt); const nextCursor = collapseExpandedComposerCursor(nextPrompt, nextPrompt.length); setComposerCursor(nextCursor); - setComposerTrigger(detectComposerTrigger(nextPrompt, nextPrompt.length)); + setComposerTrigger(detectComposerTrigger(nextPrompt, nextPrompt.length, providerSlashCommandsRef.current)); scheduleComposerFocus(); }, [scheduleComposerFocus, setPrompt], @@ -3295,7 +3380,7 @@ export default function ChatView({ threadId }: ChatViewProps) { } setComposerCursor(nextCursor); setComposerTrigger( - detectComposerTrigger(next.text, expandCollapsedComposerCursor(next.text, nextCursor)), + detectComposerTrigger(next.text, expandCollapsedComposerCursor(next.text, nextCursor), providerSlashCommandsRef.current), ); window.requestAnimationFrame(() => { composerEditorRef.current?.focusAt(nextCursor); @@ -3330,7 +3415,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const snapshot = readComposerSnapshot(); return { snapshot, - trigger: detectComposerTrigger(snapshot.value, snapshot.expandedCursor), + trigger: detectComposerTrigger(snapshot.value, snapshot.expandedCursor, providerSlashCommandsRef.current), }; }, [readComposerSnapshot]); @@ -3361,6 +3446,24 @@ export default function ChatView({ threadId }: ChatViewProps) { } return; } + if (item.type === "provider-slash-command") { + const replacement = `/${item.command} `; + const replacementRangeEnd = extendReplacementRangeForTrailingSpace( + snapshot.value, + trigger.rangeEnd, + replacement, + ); + const applied = applyPromptReplacement( + trigger.rangeStart, + replacementRangeEnd, + replacement, + { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) }, + ); + if (applied) { + setComposerHighlightedItemId(null); + } + return; + } if (item.type === "slash-command") { if (item.command === "model") { const replacement = "/model "; @@ -3459,7 +3562,7 @@ export default function ChatView({ threadId }: ChatViewProps) { } setComposerCursor(nextCursor); setComposerTrigger( - cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor), + cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor, providerSlashCommandsRef.current), ); }, [ diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index ba21af4b77..39f38d894b 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -81,6 +81,7 @@ function createMinimalSnapshot(): OrchestrationReadModel { model: "gpt-5", }, scripts: [], + cachedProviderSlashCommands: {}, createdAt: NOW_ISO, updatedAt: NOW_ISO, deletedAt: null, @@ -125,6 +126,7 @@ function createMinimalSnapshot(): OrchestrationReadModel { runtimeMode: "full-access", activeTurnId: null, lastError: null, + providerSlashCommands: [], updatedAt: NOW_ISO, }, }, diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 232da5bd09..fba3855859 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -302,6 +302,7 @@ describe("resolveThreadStatusPill", () => { createdAt: "2026-03-09T10:00:00.000Z", updatedAt: "2026-03-09T10:00:00.000Z", orchestrationStatus: "running" as const, + providerSlashCommands: [], }, }; @@ -556,6 +557,7 @@ function makeProject(overrides: Partial = {}): Project { createdAt: "2026-03-09T10:00:00.000Z", updatedAt: "2026-03-09T10:00:00.000Z", scripts: [], + cachedProviderSlashCommands: {}, ...rest, }; } diff --git a/apps/web/src/components/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx index fc7ea27c29..f305988922 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -6,6 +6,16 @@ import { cn } from "~/lib/utils"; import { Badge } from "../ui/badge"; import { Command, CommandItem, CommandList } from "../ui/command"; import { VscodeEntryIcon } from "./VscodeEntryIcon"; +import { ClaudeAI, OpenAI, type Icon } from "../Icons"; + +const PROVIDER_ICON: Record = { + codex: OpenAI, + claudeAgent: ClaudeAI, +}; + +function providerIconClassName(provider: string): string { + return provider === "claudeAgent" ? "text-[#d97757]" : "text-muted-foreground/80"; +} export type ComposerCommandItem = | { @@ -23,6 +33,14 @@ export type ComposerCommandItem = label: string; description: string; } + | { + id: string; + type: "provider-slash-command"; + provider: ProviderKind; + command: string; + label: string; + description: string; + } | { id: string; type: "model"; @@ -126,15 +144,26 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { {props.item.type === "slash-command" ? ( ) : null} + {props.item.type === "provider-slash-command" ? ( + (() => { + const ProviderIcon = PROVIDER_ICON[props.item.provider]; + return ProviderIcon ? ( +