Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/server/integration/TestProviderAdapter.integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,7 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter
readThread,
rollbackThread,
stopAll,
listCommands: () => Effect.succeed([]),
streamEvents: Stream.fromQueue(runtimeEvents),
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ function createProviderServiceHarness(
listSessions,
getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }),
rollbackConversation,
listCommands: () => Effect.succeed([]),
streamEvents: Stream.fromPubSub(runtimeEventPubSub),
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ describe("ProviderCommandReactor", () => {
sessionModelSwitch: input?.sessionModelSwitch ?? "in-session",
}),
rollbackConversation: () => unsupported(),
listCommands: () => Effect.succeed([]),
streamEvents: Stream.fromPubSub(runtimeEventPubSub),
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ function createProviderServiceHarness() {
listSessions: () => Effect.succeed([...runtimeSessions]),
getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }),
rollbackConversation: () => unsupported(),
listCommands: () => Effect.succeed([]),
streamEvents: Stream.fromPubSub(runtimeEventPubSub),
};

Expand Down
4 changes: 4 additions & 0 deletions apps/server/src/provider/Layers/ClaudeAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,10 @@ class FakeClaudeQuery implements AsyncIterable<SDKMessage> {
this.setMaxThinkingTokensCalls.push(maxThinkingTokens);
};

readonly supportedCommands = async () => {
return [];
};

readonly close = (): void => {
this.closeCalls += 1;
this.finish();
Expand Down
33 changes: 33 additions & 0 deletions apps/server/src/provider/Layers/ClaudeAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
resolveEffort,
trimOrNull,
} from "@marcode/shared/model";
import { classifyCommand } from "../claudeCommandClassification.ts";
import {
Cause,
DateTime,
Expand Down Expand Up @@ -164,12 +165,19 @@ interface ClaudeSessionContext {
stopped: boolean;
}

interface ClaudeSlashCommand {
readonly name: string;
readonly description: string;
readonly argumentHint: string;
}

interface ClaudeQueryRuntime extends AsyncIterable<SDKMessage> {
readonly interrupt: () => Promise<void>;
readonly setModel: (model?: string) => Promise<void>;
readonly setPermissionMode: (mode: PermissionMode) => Promise<void>;
readonly setMaxThinkingTokens: (maxThinkingTokens: number | null) => Promise<void>;
readonly close: () => void;
readonly supportedCommands: () => Promise<ClaudeSlashCommand[]>;
}

export interface ClaudeAdapterLiveOptions {
Expand Down Expand Up @@ -3063,6 +3071,30 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {
{ discard: true },
);

const listCommands: ClaudeAdapterShape["listCommands"] = (threadId) =>
Effect.gen(function* () {
const context = yield* requireSession(threadId);
const sdkCommands = yield* Effect.tryPromise({
try: () => context.query.supportedCommands(),
catch: (cause) =>
new ProviderAdapterRequestError({
provider: PROVIDER,
method: "supportedCommands",
detail: "Failed to retrieve supported commands from Claude runtime.",
cause,
}),
});
return sdkCommands.map((cmd) => {
const name = cmd.name.trim();
return {
name,
description: cmd.description,
argumentHint: cmd.argumentHint,
category: classifyCommand(name),
};
});
});

yield* Effect.addFinalizer(() =>
Effect.forEach(
sessions,
Expand Down Expand Up @@ -3090,6 +3122,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {
listSessions,
hasSession,
stopAll,
listCommands,
streamEvents: Stream.fromQueue(runtimeEventQueue),
} satisfies ClaudeAdapterShape;
});
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/provider/Layers/CodexAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1539,6 +1539,8 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>
manager.stopAll();
});

const listCommands: CodexAdapterShape["listCommands"] = () => Effect.succeed([]);

const runtimeEventQueue = yield* Queue.unbounded<ProviderRuntimeEvent>();

yield* Effect.acquireRelease(
Expand Down Expand Up @@ -1595,6 +1597,7 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>
listSessions,
hasSession,
stopAll,
listCommands,
streamEvents: Stream.fromQueue(runtimeEventQueue),
} satisfies CodexAdapterShape;
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const fakeCodexAdapter: CodexAdapterShape = {
readThread: vi.fn(),
rollbackThread: vi.fn(),
stopAll: vi.fn(),
listCommands: vi.fn(() => Effect.succeed([])),
streamEvents: Stream.empty,
};

Expand All @@ -42,6 +43,7 @@ const fakeClaudeAdapter: ClaudeAdapterShape = {
readThread: vi.fn(),
rollbackThread: vi.fn(),
stopAll: vi.fn(),
listCommands: vi.fn(() => Effect.succeed([])),
streamEvents: Stream.empty,
};

Expand Down
1 change: 1 addition & 0 deletions apps/server/src/provider/Layers/ProviderService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ function makeFakeCodexAdapter(provider: ProviderKind = "codex") {
readThread,
rollbackThread,
stopAll,
listCommands: vi.fn(() => Effect.succeed([])),
streamEvents: Stream.fromPubSub(runtimeEventPubSub),
};

Expand Down
14 changes: 14 additions & 0 deletions apps/server/src/provider/Layers/ProviderService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,19 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
).pipe(Effect.asVoid);
});

const listCommands: ProviderServiceShape["listCommands"] = (threadId) =>
Effect.gen(function* () {
const routed = yield* resolveRoutableSession({
threadId,
operation: "ProviderService.listCommands",
allowRecovery: false,
});
if (!routed.isActive) {
return [];
}
return yield* routed.adapter.listCommands(routed.threadId);
});

yield* Effect.addFinalizer(() =>
Effect.catch(runStopAll(), (cause) =>
Effect.logWarning("failed to stop provider service", { cause }),
Expand All @@ -542,6 +555,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
listSessions,
getCapabilities,
rollbackConversation,
listCommands,
// Each access creates a fresh PubSub subscription so that multiple
// consumers (ProviderRuntimeIngestion, CheckpointReactor, etc.) each
// independently receive all runtime events.
Expand Down
8 changes: 8 additions & 0 deletions apps/server/src/provider/Services/ProviderAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type {
ProviderSendTurnInput,
ProviderSession,
ProviderSessionStartInput,
SlashCommandDescriptor,
ThreadId,
ProviderTurnStartResult,
TurnId,
Expand Down Expand Up @@ -119,6 +120,13 @@ export interface ProviderAdapterShape<TError> {
*/
readonly stopAll: () => Effect.Effect<void, TError>;

/**
* List available slash commands for an active session.
*/
readonly listCommands: (
threadId: ThreadId,
) => Effect.Effect<ReadonlyArray<SlashCommandDescriptor>, TError>;

/**
* Canonical runtime event stream emitted by this adapter.
*/
Expand Down
8 changes: 8 additions & 0 deletions apps/server/src/provider/Services/ProviderService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import type {
ProviderSession,
ProviderSessionStartInput,
ProviderStopSessionInput,
SlashCommandDescriptor,
ThreadId,
ProviderTurnStartResult,
} from "@marcode/contracts";
Expand Down Expand Up @@ -99,6 +100,13 @@ export interface ProviderServiceShape {
readonly numTurns: number;
}) => Effect.Effect<void, ProviderServiceError>;

/**
* List available slash commands for a provider session.
*/
readonly listCommands: (
threadId: ThreadId,
) => Effect.Effect<ReadonlyArray<SlashCommandDescriptor>, ProviderServiceError>;

/**
* Canonical provider runtime event stream.
*
Expand Down
74 changes: 74 additions & 0 deletions apps/server/src/provider/claudeCommandClassification.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import type { SlashCommandCategory } from "@marcode/contracts";

export const BUILTIN_COMMAND_NAMES = new Set([
"add-dir",
"agents",
"btw",
"chrome",
"clear",
"color",
"compact",
"config",
"context",
"copy",
"cost",
"desktop",
"diff",
"doctor",
"effort",
"exit",
"export",
"extra-usage",
"fast",
"feedback",
"branch",
"help",
"hooks",
"ide",
"init",
"insights",
"install-github-app",
"install-slack-app",
"keybindings",
"login",
"logout",
"mcp",
"memory",
"mobile",
"passes",
"permissions",
"plugin",
"pr-comments",
"privacy-settings",
"release-notes",
"reload-plugins",
"remote-control",
"remote-env",
"rename",
"resume",
"review",
"rewind",
"sandbox",
"schedule",
"security-review",
"skills",
"stats",
"status",
"statusline",
"stickers",
"tasks",
"terminal-setup",
"theme",
"upgrade",
"usage",
"vim",
"voice",
]);

export const CLIENT_LOCAL_COMMANDS = new Set(["model", "plan", "default"]);

export function classifyCommand(name: string): SlashCommandCategory {
if (CLIENT_LOCAL_COMMANDS.has(name)) return "client-local";
if (BUILTIN_COMMAND_NAMES.has(name)) return "builtin";
return "skill";
}
1 change: 1 addition & 0 deletions apps/server/src/wsServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1280,6 +1280,7 @@ describe("WebSocket Server", () => {
listSessions: () => Effect.succeed([]),
getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }),
rollbackConversation: () => unsupported(),
listCommands: () => Effect.succeed([]),
streamEvents: Stream.fromPubSub(runtimeEventPubSub),
};
const providerLayer = Layer.succeed(ProviderService, providerService);
Expand Down
7 changes: 7 additions & 0 deletions apps/server/src/wsServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -608,6 +608,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
const projectionReadModelQuery = yield* ProjectionSnapshotQuery;
const checkpointDiffQuery = yield* CheckpointDiffQuery;
const orchestrationReactor = yield* OrchestrationReactor;
const providerService = yield* ProviderService;
const { openInEditor } = yield* Open;

const subscriptionsScope = yield* Scope.make("sequential");
Expand Down Expand Up @@ -935,6 +936,12 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
return yield* serverSettingsManager.updateSettings(body.patch);
}

case WS_METHODS.sessionListCommands: {
const body = stripRequestTag(request.body);
const commands = yield* providerService.listCommands(body.threadId);
return { commands };
}

default: {
const _exhaustiveCheck: never = request.body;
return yield* new RouteRequestError({
Expand Down
Loading