refactor(test): consolidate command and protocol contracts

This commit is contained in:
murashit 2026-07-17 07:39:16 +09:00
parent c37832d0a3
commit 86fc06998b
17 changed files with 430 additions and 713 deletions

View file

@ -33,38 +33,31 @@ export type ServerRequestRoute =
| { kind: "inactive"; request: ServerRequest };
type ServerRequestMethod = ServerRequest["method"];
type ServerRequestRouteKindByMethod = Record<ServerRequestMethod, Exclude<ServerRequestRoute["kind"], "inactive" | "unknown">>;
type ServerRequestScopeExtractors = {
[Method in ServerRequestMethod]: (request: Extract<ServerRequest, { method: Method }>) => AppServerRouteScope;
type ServerRequestDescriptorByMethod = {
[Method in ServerRequestMethod]: {
routeKind: Exclude<ServerRequestRoute["kind"], "inactive" | "unknown">;
scope: (request: Extract<ServerRequest, { method: Method }>) => AppServerRouteScope;
};
};
const SERVER_REQUEST_SCOPE_EXTRACTORS: ServerRequestScopeExtractors = {
"item/commandExecution/requestApproval": threadTurnRequestScope,
"item/fileChange/requestApproval": threadTurnRequestScope,
"item/tool/requestUserInput": threadTurnRequestScope,
"mcpServer/elicitation/request": threadTurnRequestScope,
"item/permissions/requestApproval": threadTurnRequestScope,
"item/tool/call": threadTurnRequestScope,
"account/chatgptAuthTokens/refresh": unscopedRequestScope,
"attestation/generate": unscopedRequestScope,
"currentTime/read": threadOnlyRequestScope,
applyPatchApproval: unscopedRequestScope,
execCommandApproval: unscopedRequestScope,
};
const SERVER_REQUEST_DESCRIPTORS = {
"item/commandExecution/requestApproval": { routeKind: "approval", scope: threadTurnRequestScope },
"item/fileChange/requestApproval": { routeKind: "approval", scope: threadTurnRequestScope },
"item/permissions/requestApproval": { routeKind: "approval", scope: threadTurnRequestScope },
"item/tool/requestUserInput": { routeKind: "userInput", scope: threadTurnRequestScope },
"mcpServer/elicitation/request": { routeKind: "mcpElicitation", scope: threadTurnRequestScope },
"item/tool/call": { routeKind: "unsupported", scope: threadTurnRequestScope },
"account/chatgptAuthTokens/refresh": { routeKind: "unsupported", scope: unscopedRequestScope },
"attestation/generate": { routeKind: "unsupported", scope: unscopedRequestScope },
"currentTime/read": { routeKind: "currentTime", scope: threadOnlyRequestScope },
applyPatchApproval: { routeKind: "unsupported", scope: unscopedRequestScope },
execCommandApproval: { routeKind: "unsupported", scope: unscopedRequestScope },
} satisfies ServerRequestDescriptorByMethod;
const SERVER_REQUEST_ROUTE_KIND_BY_METHOD: ServerRequestRouteKindByMethod = {
"item/commandExecution/requestApproval": "approval",
"item/fileChange/requestApproval": "approval",
"item/permissions/requestApproval": "approval",
"item/tool/requestUserInput": "userInput",
"mcpServer/elicitation/request": "mcpElicitation",
"item/tool/call": "unsupported",
"account/chatgptAuthTokens/refresh": "unsupported",
"attestation/generate": "unsupported",
"currentTime/read": "currentTime",
applyPatchApproval: "unsupported",
execCommandApproval: "unsupported",
};
interface ServerRequestDescriptor {
readonly routeKind: Exclude<ServerRequestRoute["kind"], "inactive" | "unknown">;
readonly scope: (request: ServerRequest) => AppServerRouteScope;
}
export function routeServerRequest(request: ServerRequest, scope: ActiveRouteScope): ServerRequestRoute {
const routeScope = serverRequestScope(request);
@ -76,7 +69,7 @@ export function routeServerRequest(request: ServerRequest, scope: ActiveRouteSco
if (!isAppServerRouteScopeInActiveRouteScope(routeScope, scope)) return { kind: "inactive", request };
if (isTurnScopedAppServerRouteForIdleActiveThread(routeScope, scope)) return { kind: "inactive", request };
switch (SERVER_REQUEST_ROUTE_KIND_BY_METHOD[request.method]) {
switch (serverRequestDescriptor(request).routeKind) {
case "approval": {
const approval = appServerApprovalRequest(request);
if (approval) return { kind: "approval", request, approval };
@ -120,12 +113,15 @@ export function serverRequestCurrentTimeResponse(currentTimeMs: number): unknown
function serverRequestScope(request: ServerRequest): AppServerRouteScope {
if (!isServerRequest(request)) return fallbackAppServerRouteScope(request);
const extractor = SERVER_REQUEST_SCOPE_EXTRACTORS[request.method] as (request: ServerRequest) => AppServerRouteScope;
return extractor(request);
return serverRequestDescriptor(request).scope(request);
}
function isServerRequest(request: ServerRequest): boolean {
return Object.hasOwn(SERVER_REQUEST_SCOPE_EXTRACTORS, request.method);
return Object.hasOwn(SERVER_REQUEST_DESCRIPTORS, request.method);
}
function serverRequestDescriptor(request: ServerRequest): ServerRequestDescriptor {
return SERVER_REQUEST_DESCRIPTORS[request.method] as ServerRequestDescriptor;
}
function threadTurnRequestScope(request: { params: { threadId: string; turnId: string | null } }): AppServerRouteScope {

View file

@ -33,6 +33,7 @@ export interface SlashCommandDefinitionShape {
argsKind: SlashCommandArgsKind;
surface: SlashCommandSurface;
detail: string;
sideChatAvailable?: false;
subcommands?: readonly SlashCommandSubcommandDefinition[];
}
@ -72,14 +73,29 @@ export const SLASH_COMMANDS = [
surface: "composition",
detail: "Fetch readable web content as context and send it with an optional message.",
},
{ command: "/fork", usage: "/fork", argsKind: "none", surface: "panelAction", detail: "Fork the active Codex thread." },
{ command: "/btw", usage: "/btw", argsKind: "none", surface: "panelAction", detail: "Open a temporary side chat." },
{
command: "/fork",
usage: "/fork",
argsKind: "none",
surface: "panelAction",
detail: "Fork the active Codex thread.",
sideChatAvailable: false,
},
{
command: "/btw",
usage: "/btw",
argsKind: "none",
surface: "panelAction",
detail: "Open a temporary side chat.",
sideChatAvailable: false,
},
{
command: "/rollback",
usage: "/rollback",
argsKind: "none",
surface: "panelAction",
detail: "Roll back the latest turn and restore its prompt.",
sideChatAvailable: false,
},
{ command: "/compact", usage: "/compact", argsKind: "none", surface: "panelAction", detail: "Compact the current thread context." },
{
@ -203,6 +219,11 @@ export function slashCommandRequiresConnection(command: SlashCommandName): boole
return !CONNECTION_INDEPENDENT_SLASH_COMMANDS.has(command);
}
export function slashCommandAvailableInSideChat(command: SlashCommandName): boolean {
const definition = slashCommandDefinition(command);
return !("sideChatAvailable" in definition);
}
export function slashCommandDefinition(command: SlashCommandName): SlashCommandDefinition {
const definition = SLASH_COMMANDS.find((item) => item.command === `/${command}`);
if (!definition) throw new Error(`Unknown slash command: ${command}`);

View file

@ -19,7 +19,13 @@ import {
selectionContextReferenceMarker,
} from "./context-references";
import type { DailyNoteReferenceCandidate } from "./daily-note-references";
import { isSlashCommandName, SLASH_COMMANDS, type SlashCommandName, slashCommandSubcommands } from "./slash-commands";
import {
isSlashCommandName,
SLASH_COMMANDS,
type SlashCommandName,
slashCommandAvailableInSideChat,
slashCommandSubcommands,
} from "./slash-commands";
export interface ComposerSuggestion {
display: string;
@ -397,8 +403,6 @@ function compareWikiLinkSuggestionTiebreakers(a: NoteCandidateMatch, b: NoteCand
return b.mtime - a.mtime || a.basename.localeCompare(b.basename) || a.path.localeCompare(b.path);
}
const SIDE_CHAT_UNAVAILABLE_SLASH_COMMANDS = new Set(["/btw", "/fork", "/rollback"]);
function activeSlashCommandSuggestions(
beforeCursor: string,
activeThreadEphemeral: boolean,
@ -415,7 +419,8 @@ function activeSlashCommandSuggestions(
const start = match.index + match[0].lastIndexOf("/");
return SLASH_COMMANDS.filter(
(item) =>
item.command.toLowerCase().startsWith(query) && (!activeThreadEphemeral || !SIDE_CHAT_UNAVAILABLE_SLASH_COMMANDS.has(item.command)),
item.command.toLowerCase().startsWith(query) &&
(!activeThreadEphemeral || slashCommandAvailableInSideChat(item.command.slice(1) as SlashCommandName)),
)
.slice(0, 8)
.map((item) => ({

View file

@ -4,7 +4,7 @@ import { runtimeConfigOrDefault } from "../../../../domain/runtime/config";
import type { Thread } from "../../../../domain/threads/model";
import { resolveRuntimeControls } from "../../domain/runtime/resolution";
import type { ComposerInputSnapshot } from "../composer/input-snapshot";
import type { SlashCommandName } from "../composer/slash-commands";
import { type SlashCommandName, slashCommandRequiresConnection } from "../composer/slash-commands";
import { runtimeSnapshotForChatState } from "../runtime/snapshot";
import type { ChatStateStore } from "../state/store";
import {
@ -35,7 +35,7 @@ export async function executeSlashCommandWithState(
if (state.activeThreadSubagent) {
throw new Error("Slash commands are unavailable in agent threads. Start a new chat to continue.");
}
if (!host.connectionAvailable() && command !== "reconnect" && command !== "compact") return;
if (!host.connectionAvailable() && slashCommandRequiresConnection(command)) return;
return runSlashCommand(command, args, {
...host,
activeThreadId: state.activeThreadId,

View file

@ -4,25 +4,15 @@ import {
appServerApprovalResponse as approvalResponse,
appServerApprovalRequest as toPendingApproval,
} from "../../../../src/app-server/protocol/server-requests";
import { createApprovalResultItem } from "../../../../src/features/chat/domain/pending-requests/result-items";
import { pendingRequestBlockSnapshotFromState } from "../../../../src/features/chat/presentation/pending-requests/view-model";
import { defaultPendingApprovalOptions, type PendingApproval } from "../../../../src/domain/pending-requests/model";
function expectPresent<T>(value: T | null | undefined): T {
if (value === null || value === undefined) throw new Error("Expected value to be present");
return value;
}
function approvalActionOptions(approval: NonNullable<ReturnType<typeof toPendingApproval>>) {
return expectPresent(
pendingRequestBlockSnapshotFromState({
approvals: [approval],
pendingUserInputs: [],
pendingMcpElicitations: [],
userInputDrafts: new Map(),
mcpElicitationDrafts: new Map(),
approvalDetails: new Set(),
}).approvals[0],
).actions;
function approvalActionOptions(approval: PendingApproval) {
return approval.actionOptions && approval.actionOptions.length > 0 ? approval.actionOptions : defaultPendingApprovalOptions();
}
function approvalActionLabels(approval: NonNullable<ReturnType<typeof toPendingApproval>>): (string | null)[] {
@ -135,17 +125,10 @@ describe("approval model", () => {
const options = approvalActionOptions(approval);
expect(options.map((option) => option.label)).toEqual(["Allow network rule", "Allow network rule", "Deny"]);
expect(options.map((option) => option.className)).toEqual(["", "", "mod-warning"]);
expect(new Set(options.map((option) => option.id)).size).toBe(options.length);
expect(approvalResponseAt(approval, 0)).toEqual({ decision: allowRegistryDecision });
expect(approvalResponseAt(approval, 1)).toEqual({ decision: allowApiDecision });
expect(approvalResponseAt(approval, 2)).toEqual({ decision: "decline" });
expect(createApprovalResultItem(approval, expectPresent(options[0]).action)).toMatchObject({
approval: { status: "allowed for session" },
});
expect(createApprovalResultItem(approval, expectPresent(options[2]).action)).toMatchObject({
approval: { status: "denied" },
});
});
it("keeps future simple command decisions renderable", () => {
@ -176,7 +159,7 @@ describe("approval model", () => {
id: "approval-option:0:restartWithNetwork",
label: "Choose",
action: { kind: "approval-option", intent: "decline", response: { decision: futureDecision } },
className: "mod-warning",
intent: "decline",
},
]);
expect(approvalResponseAt(approval, 0)).toEqual({ decision: futureDecision });

View file

@ -5,7 +5,6 @@ import {
appServerUserInputRequest as toPendingUserInput,
} from "../../../../src/app-server/protocol/server-requests";
import { answersForPendingUserInput, questionDefaultAnswer } from "../../../../src/domain/pending-requests/model";
import { pendingRequestFocusSignature, pendingRequestsSignature } from "../../../../src/features/chat/domain/pending-requests/signatures";
function expectPresent<T>(value: T | null | undefined): T {
if (value === null || value === undefined) throw new Error("Expected value to be present");
@ -44,67 +43,4 @@ describe("user input model", () => {
answers: { direction: { answers: ["Recommended"] } },
});
});
it("signs pending request content and drafts deterministically", () => {
const input = expectPresent(
toPendingUserInput({
id: 7,
method: "item/tool/requestUserInput",
params: {
threadId: "thread",
turnId: "turn",
itemId: "item",
questions: [
{
id: "direction",
header: "Direction",
question: "Which way?",
isOther: true,
isSecret: false,
options: [{ label: "Recommended", description: "Use the default path" }],
},
],
autoResolutionMs: null,
},
}),
);
const drafts = new Map([
["z", "last"],
["a", "first"],
]);
expect(pendingRequestsSignature([], [], [], drafts, new Map())).toBe("");
expect(pendingRequestFocusSignature([], [], [])).toBe("");
expect(pendingRequestFocusSignature([], [input], [])).toBe(
JSON.stringify({
approvals: [],
inputs: [{ id: 7 }],
mcpElicitations: [],
}),
);
expect(pendingRequestsSignature([], [input], [], drafts, new Map())).toBe(
JSON.stringify({
approvals: [],
inputs: [
{
id: 7,
questions: [
{
id: "direction",
header: "Direction",
question: "Which way?",
options: ["Recommended"],
},
],
},
],
mcpElicitations: [],
drafts: [
["a", "first"],
["z", "last"],
],
mcpDrafts: [],
}),
);
});
});

View file

@ -17,7 +17,6 @@ import {
} from "../../../../../src/features/chat/application/state/root-reducer";
import type { ChatStateStore } from "../../../../../src/features/chat/application/state/store";
import { pendingTurnStart } from "../../../../../src/features/chat/application/turns/turn-state";
import { attachHookRunsToTurn } from "../../../../../src/features/chat/domain/thread-stream/updates";
import type { ThreadCatalogEvent } from "../../../../../src/features/threads/catalog/thread-catalog";
import { chatStateFixture, chatStateWith } from "../../support/state";
import { chatStateThreadStreamItems, withChatStateThreadStreamItems } from "../../support/thread-stream";
@ -248,91 +247,6 @@ describe("ChatInboundHandler", () => {
expect(handler.currentState().threadStream.turnDiffs.size).toBe(0);
});
it("formats hook runs as compact summaries with details", () => {
const state = activeRunningState();
const handler = handlerForState(state);
handler.handleNotification({
method: "hook/completed",
params: {
threadId: "thread-active",
turnId: "turn-active",
run: {
id: "hook-1",
eventName: "postToolUse",
handlerType: "command",
executionMode: "sync",
scope: "turn",
sourcePath: "/vault/.codex/hooks.json",
source: "project",
displayOrder: 1n,
status: "completed",
statusMessage: "Formatted 1 file.",
startedAt: 1n,
completedAt: 2n,
durationMs: 1n,
entries: [{ kind: "feedback", text: "ok" }],
},
},
} satisfies Extract<ServerNotification, { method: "hook/completed" }>);
expect(chatStateThreadStreamItems(handler.currentState())).toMatchObject([
{
id: "hook-hook-1-1",
kind: "hook",
toolName: "hook",
operation: "postToolUse",
primaryTarget: { kind: "value", value: "Formatted 1 file." },
status: "completed",
executionState: "completed",
output: "",
hookRun: {
eventName: "postToolUse",
statusMessage: "Formatted 1 file.",
durationMs: "1ms",
entries: [{ kind: "feedback", text: "ok" }],
},
},
]);
});
it("omits hook duration details while duration is unavailable", () => {
const state = activeRunningState();
const handler = handlerForState(state);
handler.handleNotification({
method: "hook/completed",
params: {
threadId: "thread-active",
turnId: "turn-active",
run: {
id: "hook-1",
eventName: "postToolUse",
handlerType: "command",
executionMode: "sync",
scope: "turn",
sourcePath: "/vault/.codex/hooks.json",
source: "project",
displayOrder: 1n,
status: "completed",
statusMessage: null,
startedAt: 1n,
completedAt: null,
durationMs: null,
entries: [],
},
},
} satisfies Extract<ServerNotification, { method: "hook/completed" }>);
expect(chatStateThreadStreamItems(handler.currentState())[0]).toMatchObject({
kind: "hook",
hookRun: {
eventName: "postToolUse",
entries: [],
},
});
});
it("attaches unscoped hook runs to the active turn while streaming", () => {
const state = activeRunningState();
const handler = handlerForState(state);
@ -489,37 +403,6 @@ describe("ChatInboundHandler", () => {
expect(refreshActiveThreads).not.toHaveBeenCalled();
});
it("moves pre-turn hook runs after the optimistic user message when a turn id is assigned", () => {
const items = attachHookRunsToTurn(
[
{
id: "hook-old-1",
kind: "hook",
role: "tool",
text: "userPromptSubmit: Stale hook",
toolName: "hook",
status: "completed",
},
{
id: "hook-hook-1-1",
kind: "hook",
role: "tool",
text: "userPromptSubmit: Saving jj baseline",
toolName: "hook",
status: "completed",
},
{ id: "local-user-1", kind: "dialogue", dialogueKind: "user", role: "user", text: "hello" },
],
"turn-active",
["hook-hook-1-1"],
"local-user-1",
);
expect(items.map((item) => item.id)).toEqual(["hook-old-1", "local-user-1", "hook-hook-1-1"]);
expect(items[0]?.turnId).toBeUndefined();
expect(items[2]).toMatchObject({ id: "hook-hook-1-1", turnId: "turn-active" });
});
it("captures only prompt-submit hooks observed during the pending turn start", () => {
let state = chatStateFixture();
state = chatStateWith(state, { activeThread: { id: "thread-active" } });
@ -999,49 +882,27 @@ describe("ChatInboundHandler", () => {
expect(chatStateThreadStreamItems(handler.currentState())).toEqual([]);
});
it("handles known server request families and rejects unsupported requests by default", () => {
it("responds to current-time requests and rejects a representative known unsupported request", () => {
const state = chatStateFixture();
const rejectServerRequest = vi.fn(() => true);
const respondToServerRequest = vi.fn(() => true);
const handler = handlerForState(state, { rejectServerRequest, respondToServerRequest });
for (const request of supportedApprovalRequests()) {
handler.handleServerRequest(request);
}
handler.handleServerRequest(userInputRequest(20));
handler.handleServerRequest(mcpElicitationRequest(21));
const dateNow = vi.spyOn(Date, "now").mockReturnValue(1_700_000_123_456);
handler.handleServerRequest(currentTimeRequest(28, "thread"));
const unsupported = unsupportedRequests();
for (const request of unsupported) {
handler.handleServerRequest(request);
}
dateNow.mockRestore();
handler.handleServerRequest(unknownRequest());
const unsupported = {
id: 22,
method: "item/tool/call",
params: { threadId: "thread", turnId: "turn", callId: "call", namespace: null, tool: "tool", arguments: {} },
} satisfies Extract<ServerRequest, { method: "item/tool/call" }>;
handler.handleServerRequest(unsupported);
expect(handler.currentState().requests.approvals.map((approval) => approval.requestId)).toEqual([10, 11, 12]);
expect(handler.currentState().requests.pendingUserInputs.map((input) => input.requestId)).toEqual([20]);
expect(handler.currentState().requests.pendingMcpElicitations.map((elicitation) => elicitation.requestId)).toEqual([21]);
expect(respondToServerRequest).toHaveBeenCalledWith(28, { currentTimeAt: 1_700_000_123 });
const unsupportedMessages = unsupported.map((request) => `Rejected unsupported app-server request: ${request.method}`);
expect(rejectServerRequest).toHaveBeenCalledTimes(unsupportedMessages.length + 1);
for (const [index, request] of unsupported.entries()) {
expect(rejectServerRequest).toHaveBeenNthCalledWith(index + 1, request.id, -32601, unsupportedMessages[index]);
}
expect(rejectServerRequest).toHaveBeenNthCalledWith(
unsupportedMessages.length + 1,
27,
-32601,
"Rejected unknown app-server request: appServer/newFutureRequest",
);
expect(chatStateThreadStreamItems(handler.currentState()).map((item) => ("text" in item ? item.text : ""))).toEqual(
unsupportedMessages,
);
expect(
chatStateThreadStreamItems(handler.currentState())
.map((item) => ("text" in item ? item.text : ""))
.join("\n"),
).not.toContain("do-not-render");
expect(rejectServerRequest).toHaveBeenCalledWith(22, -32601, "Rejected unsupported app-server request: item/tool/call");
expect(chatStateThreadStreamItems(handler.currentState()).map((item) => ("text" in item ? item.text : ""))).toEqual([
"Rejected unsupported app-server request: item/tool/call",
]);
});
it("keeps unknown server request fallback out of the normal thread stream", () => {
@ -1289,65 +1150,8 @@ describe("ChatInboundHandler", () => {
expect(chatStateThreadStreamItems(handler.currentState())).toEqual([]);
});
it("clears all active-thread scoped state when the active thread is archived", () => {
let state = activeRunningState();
state = chatStateWith(state, { runtime: { active: { model: "gpt-5.5" } } });
state = chatStateWith(state, { runtime: { active: { serviceTier: "fast" } } });
state = chatStateWith(state, {
activeThread: {
tokenUsage: {
last: { inputTokens: 1, cachedInputTokens: 0, outputTokens: 2, reasoningOutputTokens: 0, totalTokens: 3 },
total: { inputTokens: 1, cachedInputTokens: 0, outputTokens: 2, reasoningOutputTokens: 0, totalTokens: 3 },
modelContextWindow: 100,
},
},
});
state = chatStateWith(state, { threadStream: { historyCursor: "cursor" } });
state = chatStateWith(state, { threadStream: { loadingHistory: true } });
state = withChatStateThreadStreamItems(state, [
{
id: "message",
kind: "dialogue",
role: "assistant",
text: "stale",
dialogueKind: "assistantResponse",
dialogueState: "completed",
},
]);
state = chatStateWith(state, { threadStream: { turnDiffs: new Map([["turn-active", "@@\n-stale\n+stale"]]) } });
state = chatStateWith(state, { composer: { draft: "thread draft" } });
state = chatStateWith(state, {
requests: {
approvals: [
pendingApprovalFromRequest({
id: 10,
method: "item/commandExecution/requestApproval",
params: expectPresent(supportedApprovalRequests()[0]).params as Extract<
ServerRequest,
{ method: "item/commandExecution/requestApproval" }
>["params"],
}),
],
},
});
state = chatStateWith(state, {
requests: {
pendingUserInputs: [
pendingUserInputFromRequest({
id: 20,
method: "item/tool/requestUserInput",
params: {
threadId: "thread-active",
turnId: "turn-active",
itemId: "input",
questions: [{ id: "note", header: "Note", question: "What now?", isOther: false, isSecret: false, options: null }],
autoResolutionMs: null,
},
}),
],
},
});
state = chatStateWith(state, { requests: { userInputDrafts: new Map([["20:note", "draft"]]) } });
it("routes active-thread archive notifications to the shared catalog without clearing the panel", () => {
const state = chatStateWith(chatStateFixture(), { activeThread: { id: "thread-active" } });
const applyThreadCatalogEvent = vi.fn();
const handler = handlerForState(state, { applyThreadCatalogEvent });
@ -1493,52 +1297,6 @@ describe("ChatInboundHandler", () => {
expect(applyThreadCatalogEvent).not.toHaveBeenCalled();
});
it("replaces optimistic user echoes when completed turns are reconciled", () => {
let state = activeRunningState();
state = withChatStateThreadStreamItems(state, [
{ id: "local-user-1", kind: "dialogue", dialogueKind: "user", role: "user", text: "hello", turnId: "turn-active" },
{
id: "a1",
sourceItemId: "a1",
kind: "dialogue",
role: "assistant",
dialogueKind: "assistantResponse",
dialogueState: "completed",
text: "partial",
turnId: "turn-active",
},
]);
const handler = handlerForState(state);
handler.handleNotification({
method: "turn/completed",
params: {
threadId: "thread-active",
turn: {
id: "turn-active",
status: "completed",
error: null,
startedAt: null,
completedAt: null,
durationMs: null,
itemsView: "full",
items: [
{ type: "userMessage", id: "u1", clientId: null, content: [{ type: "text", text: "hello", text_elements: [] }] },
{ type: "agentMessage", id: "a1", text: "done", phase: "final_answer", memoryCitation: null },
],
},
},
} satisfies Extract<ServerNotification, { method: "turn/completed" }>);
expect(chatStateThreadStreamItems(handler.currentState()).filter((item) => item.kind === "dialogue" && item.role === "user")).toEqual(
[expect.objectContaining({ id: "u1", text: "hello" })],
);
expect(chatStateThreadStreamItems(handler.currentState())).toEqual(
expect.arrayContaining([expect.objectContaining({ id: "a1", text: "done" })]),
);
expect(chatStateThreadStreamItems(handler.currentState()).some((item) => item.id === "local-user-1")).toBe(false);
});
it("reconciles optimistic user echoes by client id before falling back to same-turn text only when client ids are absent", () => {
let state = activeRunningState();
state = withChatStateThreadStreamItems(state, [
@ -2009,39 +1767,6 @@ describe("ChatInboundHandler", () => {
expect(chatStateThreadStreamItems(handler.currentState())).toHaveLength(1);
});
it("ignores goal notifications that do not match the active thread", () => {
const goal = {
threadId: "previous-thread",
objective: "Stale",
status: "active",
tokenBudget: null,
tokensUsed: 0,
timeUsedSeconds: 0,
createdAt: 1,
updatedAt: 1,
} satisfies Extract<ServerNotification, { method: "thread/goal/updated" }>["params"]["goal"];
const noActiveState = chatStateFixture();
const noActiveHandler = handlerForState(noActiveState);
noActiveHandler.handleNotification({
method: "thread/goal/updated",
params: { threadId: "previous-thread", turnId: null, goal },
} satisfies Extract<ServerNotification, { method: "thread/goal/updated" }>);
expect(activeThreadState(noActiveState)).toBeNull();
let otherThreadState = chatStateFixture();
otherThreadState = chatStateWith(otherThreadState, { activeThread: { id: "thread-active" } });
otherThreadState = chatStateWith(otherThreadState, {
activeThread: { goal: { ...goal, threadId: "thread-active", objective: "Current" } },
});
const otherThreadHandler = handlerForState(otherThreadState);
otherThreadHandler.handleNotification({
method: "thread/goal/cleared",
params: { threadId: "previous-thread" },
} satisfies Extract<ServerNotification, { method: "thread/goal/cleared" }>);
expect(expectPresent(activeThreadState(otherThreadState)?.goal).objective).toBe("Current");
});
});
describe("auto-review display", () => {
@ -2305,44 +2030,6 @@ function promptSubmitHookRun(id: string, startedAt: bigint): Extract<ServerNotif
};
}
function unsupportedRequests(): ServerRequest[] {
return [
{
id: 22,
method: "item/tool/call",
params: { threadId: "thread", turnId: "turn", callId: "call", namespace: null, tool: "tool", arguments: {} },
},
{
id: 23,
method: "account/chatgptAuthTokens/refresh",
params: { reason: "unauthorized", previousAccountId: null },
},
{
id: 24,
method: "attestation/generate",
params: {},
},
{
id: 25,
method: "applyPatchApproval",
params: { conversationId: "thread", callId: "patch", fileChanges: {}, reason: "patch", grantRoot: null },
},
{
id: 26,
method: "execCommandApproval",
params: {
conversationId: "thread",
callId: "exec",
approvalId: null,
command: ["npm", "test"],
cwd: "/tmp/project",
reason: "exec",
parsedCmd: [],
},
},
];
}
function currentTimeRequest(id: number, threadId: string): ServerRequest {
return {
id,

View file

@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import type { ServerNotification } from "../../../../../src/app-server/connection/rpc-messages";
import { planChatNotification } from "../../../../../src/features/chat/app-server/inbound/notification-plan";
import { chatStateFixture } from "../../support/state";
describe("chat notification plan", () => {
it("owns active-thread goal projection and ignores other-thread goals", () => {
const currentGoal = {
threadId: "thread-active",
objective: "Current",
status: "active",
tokenBudget: null,
tokensUsed: 0,
timeUsedSeconds: 0,
createdAt: 1,
updatedAt: 1,
} satisfies Extract<ServerNotification, { method: "thread/goal/updated" }>["params"]["goal"];
const nextGoal = { ...currentGoal, objective: "Next", updatedAt: 2 };
const state = chatStateFixture({ activeThread: { id: "thread-active", goal: currentGoal } });
expect(
planChatNotification(
state,
{ method: "thread/goal/updated", params: { threadId: "thread-other", turnId: null, goal: nextGoal } },
(prefix) => `${prefix}-1`,
),
).toEqual({ actions: [], effects: [] });
expect(
planChatNotification(
state,
{ method: "thread/goal/updated", params: { threadId: "thread-active", turnId: null, goal: nextGoal } },
(prefix) => `${prefix}-1`,
).actions,
).toEqual([
{ type: "active-thread/goal-set", goal: nextGoal },
{ type: "thread-stream/item-upserted", item: expect.objectContaining({ id: "goal-1", kind: "goal", objective: "Next" }) },
]);
});
});

View file

@ -113,6 +113,7 @@ describe("chat inbound routing", () => {
{ name: "dynamic tool call", request: dynamicToolCallRequest(), kind: "unsupported" },
] as const)("classifies $name server requests and extracts scope", ({ request, kind }) => {
expectRequestRouteKind(request, kind);
expectRequestRouteKind(request, kind, { activeThreadId: null, activeTurnId: null });
if ("turnId" in request.params) {
expectRequestRouteKind({ ...request, params: { ...request.params, turnId: "turn-other" } } as ServerRequest, "inactive");
}
@ -197,15 +198,6 @@ describe("chat inbound routing", () => {
);
});
it.each([
{ name: "command approval", request: commandApprovalRequest(), kind: "approval" },
{ name: "user input", request: userInputRequest(), kind: "userInput" },
{ name: "MCP elicitation", request: mcpElicitationRequest(), kind: "mcpElicitation" },
{ name: "current time", request: currentTimeRequest("thread-active"), kind: "currentTime" },
] as const)("classifies $name before unsupported requests", ({ request, kind }) => {
expectRequestRouteKind(request, kind, { activeThreadId: null, activeTurnId: null });
});
it("classifies inactive requests before request-family handling", () => {
const route = routeServerRequest(userInputRequest(), activeScope);

View file

@ -9,9 +9,9 @@ describe("app-server turn runtime event mapping", () => {
params: { threadId: "thread-active", turnId: "turn-active", itemId: "a1", delta: "hello" },
} satisfies Extract<ServerNotification, { method: "item/agentMessage/delta" }>;
expect(turnRuntimeEventsFromNotification(notification, (prefix) => `${prefix}-1`)).toEqual([
{ type: "assistantDelta", turnId: "turn-active", itemId: "a1", delta: "hello", completeReasoning: true },
]);
const events = turnRuntimeEventsFromNotification(notification, (prefix) => `${prefix}-1`);
expect(events).toEqual([{ type: "assistantDelta", turnId: "turn-active", itemId: "a1", delta: "hello", completeReasoning: true }]);
});
it("maps completed turns to completed turn snapshots", () => {
@ -53,4 +53,56 @@ describe("app-server turn runtime event mapping", () => {
],
});
});
it("maps hook runs to compact items while omitting unavailable duration", () => {
const notification = {
method: "hook/completed",
params: {
threadId: "thread-active",
turnId: "turn-active",
run: {
id: "hook-1",
eventName: "postToolUse",
handlerType: "command",
executionMode: "sync",
scope: "turn",
sourcePath: "/vault/.codex/hooks.json",
source: "project",
displayOrder: 1n,
status: "completed",
statusMessage: "Formatted 1 file.",
startedAt: 1n,
completedAt: null,
durationMs: null,
entries: [{ kind: "feedback", text: "ok" }],
},
},
} satisfies Extract<ServerNotification, { method: "hook/completed" }>;
const events = turnRuntimeEventsFromNotification(notification, (prefix) => `${prefix}-1`);
expect(events).toEqual([
{
type: "hookRunObserved",
turnId: "turn-active",
eventName: "postToolUse",
item: expect.objectContaining({
id: "hook-hook-1-1",
kind: "hook",
operation: "postToolUse",
primaryTarget: { kind: "value", value: "Formatted 1 file." },
executionState: "completed",
hookRun: {
eventName: "postToolUse",
statusMessage: "Formatted 1 file.",
entries: [{ kind: "feedback", text: "ok" }],
},
}),
},
]);
const event = events[0];
if (event?.type !== "hookRunObserved") throw new Error("Expected a hook runtime event");
if (event.item.kind !== "hook") throw new Error("Expected a hook item");
expect(event.item.hookRun).not.toHaveProperty("durationMs");
});
});

View file

@ -0,0 +1,67 @@
import { describe, expect, it, vi } from "vitest";
import type { AppServerClient } from "../../../../src/app-server/connection/client";
import type { CodexInput } from "../../../../src/domain/chat/input";
import { createThreadReferenceResolver } from "../../../../src/features/chat/app-server/thread-reference-resolver";
const textInput = (text: string): CodexInput => [{ type: "text", text }];
describe("thread reference resolver", () => {
it("reads referenced history and prepares one bundled input", async () => {
const request = vi.fn().mockResolvedValue({
data: [
{
id: "turn",
itemsView: "full",
status: "completed",
error: null,
startedAt: null,
completedAt: null,
durationMs: null,
items: [
{ type: "userMessage", id: "u1", clientId: null, content: [{ type: "text", text: "元の依頼", text_elements: [] }] },
{ type: "agentMessage", id: "a1", text: "回答", phase: "final_answer", memoryCitation: null },
],
},
],
nextCursor: null,
});
const client = { request } as unknown as AppServerClient;
const setStatus = vi.fn();
const inputSnapshot = { sourcePath: "snapshot.md" } as never;
const prepareInput = vi.fn((text: string) => ({ text, input: textInput(text) }));
const resolver = createThreadReferenceResolver({
currentClient: () => client,
prepareInput,
addSystemMessage: vi.fn(),
setStatus,
});
const result = await resolver.referThread(
{
id: "019abcde-0000-7000-8000-000000000001",
preview: "",
name: "Other",
createdAt: 1,
updatedAt: 1,
archived: false,
provenance: { kind: "interactive" },
},
"summarize",
inputSnapshot,
);
expect(request).toHaveBeenCalledWith("thread/turns/list", {
threadId: "019abcde-0000-7000-8000-000000000001",
cursor: null,
limit: 20,
sortDirection: "desc",
itemsView: "full",
});
expect(result?.input[0]).toMatchObject({ type: "text", text: expect.stringContaining("Reference thread history:") });
expect(result?.text).toBe("summarize");
expect(prepareInput).toHaveBeenCalledWith("summarize", inputSnapshot);
expect(result?.referencedThread).toMatchObject({ title: "Other", includedTurns: 1, turnLimit: 20 });
expect(setStatus).toHaveBeenCalledWith("Referencing 019abcde (1/20 turns).");
});
});

View file

@ -7,7 +7,6 @@ import type { TurnItem, TurnRecord } from "../../../../src/app-server/protocol/t
import type { CodexInput } from "../../../../src/domain/chat/input";
import { createServerDiagnostics, diagnosticProbeOk } from "../../../../src/domain/server/diagnostics";
import { createChatAppServerGateway, createChatCurrentAppServerGateway } from "../../../../src/features/chat/app-server/session-gateway";
import { createThreadReferenceResolver } from "../../../../src/features/chat/app-server/thread-reference-resolver";
import { preparedUserInputWithWikiLinkMentionsSkillsAndContext } from "../../../../src/features/chat/application/composer/wikilink-context";
import { deferred } from "../../../support/async";
@ -436,53 +435,6 @@ describe("chat app-server transports", () => {
expect(request).toHaveBeenCalledWith("skills/list", { cwds: ["/vault"], forceReload: false });
});
it("resolves referenced thread input at the app-server boundary", async () => {
const request = vi.fn().mockResolvedValue({
data: [turn([userMessage("u1", "元の依頼"), agentMessage("a1", "回答")])],
nextCursor: null,
});
const client = { request } as unknown as AppServerClient;
const setStatus = vi.fn();
const inputSnapshot = { sourcePath: "snapshot.md" } as never;
const prepareInput = vi.fn((text: string) => ({ text, input: textInput(text) }));
const resolver = createThreadReferenceResolver({
currentClient: () => client,
prepareInput,
addSystemMessage: vi.fn(),
setStatus,
});
const result = await resolver.referThread(
{
id: "019abcde-0000-7000-8000-000000000001",
preview: "",
name: "Other",
createdAt: 1,
updatedAt: 1,
archived: false,
provenance: { kind: "interactive" },
},
"summarize",
inputSnapshot,
);
expect(request).toHaveBeenCalledWith("thread/turns/list", {
threadId: "019abcde-0000-7000-8000-000000000001",
cursor: null,
limit: 20,
sortDirection: "desc",
itemsView: "full",
});
expect(result?.input[0]).toMatchObject({
type: "text",
text: expect.stringContaining("Reference thread history:"),
});
expect(result?.text).toBe("summarize");
expect(prepareInput).toHaveBeenCalledWith("summarize", inputSnapshot);
expect(result?.referencedThread).toMatchObject({ title: "Other", includedTurns: 1, turnLimit: 20 });
expect(setStatus).toHaveBeenCalledWith("Referencing 019abcde (1/20 turns).");
});
it("uses a short-lived client for clientAccess operations that reject server requests", async () => {
const currentRequest = vi.fn();
const currentClient = { request: currentRequest } as unknown as AppServerClient;

View file

@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import {
SLASH_COMMANDS,
type SlashCommandName,
slashCommandAvailableInSideChat,
slashCommandHelpSections,
slashCommandRequiresConnection,
} from "../../../../../src/features/chat/application/composer/slash-commands";
describe("slash command catalog", () => {
it("defines unique command names and usage rows", () => {
expect(new Set(SLASH_COMMANDS.map((definition) => definition.command)).size).toBe(SLASH_COMMANDS.length);
expect(SLASH_COMMANDS.every((definition) => definition.usage.startsWith(definition.command))).toBe(true);
});
it("groups help by command surface and expands catalog subcommands", () => {
const sections = slashCommandHelpSections();
const keys = (title: string) => sections.find((section) => section.title === title)?.auditFacts.map((row) => row.key) ?? [];
expect(sections.map((section) => section.title)).toEqual(["Panel actions", "Thread settings", "Diagnostics", "Composition"]);
expect(keys("Panel actions")).toEqual(expect.arrayContaining(["/clear", "/reconnect", "/archive <thread>", "/rename <thread> <name>"]));
expect(keys("Thread settings")).toEqual(
expect.arrayContaining(["/plan [message]", "/goal", "/goal set <objective>", "/goal edit", "/permissions [profile|default]"]),
);
expect(keys("Thread settings")).not.toContain("/goal [set <objective>|edit|pause|resume|clear]");
expect(keys("Diagnostics")).toEqual(expect.arrayContaining(["/status", "/doctor", "/tools", "/help"]));
expect(keys("Composition")).toEqual(["/refer <thread> <message>", "/web <url> [message]"]);
});
it("owns connection and side-chat availability metadata", () => {
expect(
SLASH_COMMANDS.filter((definition) => !slashCommandRequiresConnection(definition.command.slice(1) as SlashCommandName)).map(
(item) => item.command,
),
).toEqual(["/reconnect", "/compact"]);
expect(
SLASH_COMMANDS.filter((definition) => !slashCommandAvailableInSideChat(definition.command.slice(1) as SlashCommandName)).map(
(item) => item.command,
),
).toEqual(["/fork", "/btw", "/rollback"]);
});
});

View file

@ -70,6 +70,72 @@ describe("thread management actions", () => {
expect(host.addSystemMessage).toHaveBeenCalledWith("Side chats cannot be forked.");
});
it.each([
{
name: "fork",
invoke: (actions: ThreadManagementActions) => actions.forkThread("agent-thread"),
transport: "forkThread" as const,
message: "Agent threads cannot be forked.",
},
{
name: "rollback",
invoke: (actions: ThreadManagementActions) => actions.rollbackThread("agent-thread"),
transport: "rollbackThread" as const,
message: "Agent threads cannot be rolled back.",
},
])("rejects $name mutations for subagent threads", async ({ invoke, transport, message }) => {
const host = hostMock({
items: turnItems(),
activeThread: {
id: "agent-thread",
provenance: {
kind: "subagent",
subagentKind: "thread-spawn",
parentThreadId: "parent",
sessionId: "session",
depth: 1,
agentNickname: "Scout",
agentRole: "explorer",
},
},
});
await invoke(threadManagementActions(host));
expect(host.threadTransport[transport]).not.toHaveBeenCalled();
expect(host.addSystemMessage).toHaveBeenCalledWith(message);
});
it.each([
{
name: "archive",
invoke: (actions: ThreadManagementActions) => actions.archiveThread("source"),
operation: "archiveThread" as const,
message: "Finish or interrupt the current turn before archiving threads.",
},
{
name: "fork",
invoke: (actions: ThreadManagementActions) => actions.forkThread("source"),
operation: "forkThread" as const,
message: "Finish or interrupt the current turn before forking threads.",
},
{
name: "rollback",
invoke: (actions: ThreadManagementActions) => actions.rollbackThread("source"),
operation: "rollbackThread" as const,
message: "Interrupt the current turn before rolling back.",
},
])("rejects $name while a turn is busy", async ({ invoke, operation, message }) => {
const host = hostMock({ items: turnItems(), activeThread: { id: "source" } });
host.stateStore.dispatch({ type: "turn/started", threadId: "source", turnId: "turn-running" });
await invoke(threadManagementActions(host));
if (operation === "archiveThread") expect(host.operations.archiveThread).not.toHaveBeenCalled();
else expect(host.threadTransport[operation]).not.toHaveBeenCalled();
expect(host.addSystemMessage).toHaveBeenCalledWith(message);
});
it("requests thread compaction and reports the shared status", async () => {
const host = hostMock({ items: [] });
const controller = threadManagementActions(host);

View file

@ -2,7 +2,11 @@ import { describe, expect, it, vi } from "vitest";
import type { ThreadGoal } from "../../../../../src/domain/threads/goal";
import type { Thread } from "../../../../../src/domain/threads/model";
import { slashCommandHelpSections } from "../../../../../src/features/chat/application/composer/slash-commands";
import {
SLASH_COMMANDS,
type SlashCommandName,
slashCommandHelpSections,
} from "../../../../../src/features/chat/application/composer/slash-commands";
import {
executeSlashCommand,
type SlashCommandExecutionContext,
@ -95,6 +99,19 @@ function goal(overrides: Partial<ThreadGoal> = {}): ThreadGoal {
}
describe("slash commands", () => {
it.each(
SLASH_COMMANDS.filter((definition) => definition.argsKind === "none").map((definition) => [
definition.command.slice(1) as SlashCommandName,
definition.usage,
]),
)("rejects arguments for /%s from catalog metadata", async (command, usage) => {
const ctx = context();
await executeSlashCommand(command, "unexpected", ctx);
expect(ctx.addSystemMessage).toHaveBeenCalledWith(`/${command} does not take arguments. Usage: ${usage}`);
});
it("clears the current panel for /clear", async () => {
const ctx = context();
@ -103,15 +120,6 @@ describe("slash commands", () => {
expect(ctx.startNewThread).toHaveBeenCalledOnce();
});
it("rejects /clear arguments", async () => {
const ctx = context();
await executeSlashCommand("clear", "最初の依頼です", ctx);
expect(ctx.startNewThread).not.toHaveBeenCalled();
expect(ctx.addSystemMessage).toHaveBeenCalledWith("/clear does not take arguments. Usage: /clear");
});
it("resumes the latest listed thread for bare /resume", async () => {
const ctx = context({
listedThreads: [thread({ id: "latest", name: "Latest" }), thread({ id: "older", name: "Older" })],
@ -291,15 +299,6 @@ describe("slash commands", () => {
expect(ctx.threadActions.forkThread).toHaveBeenCalledWith("active-thread");
});
it("rejects /fork arguments", async () => {
const ctx = context();
await executeSlashCommand("fork", "anything", ctx);
expect(ctx.threadActions.forkThread).not.toHaveBeenCalled();
expect(ctx.addSystemMessage).toHaveBeenCalledWith("/fork does not take arguments. Usage: /fork");
});
it("does not fork a side chat", async () => {
const ctx = context({ activeThreadId: "side-thread", activeThreadEphemeral: true });
@ -354,24 +353,6 @@ describe("slash commands", () => {
expect(ctx.addSystemMessage).toHaveBeenCalledWith("No active thread to roll back.");
});
it("delegates rollback running-turn checks to thread actions", async () => {
const ctx = context();
await executeSlashCommand("rollback", "", ctx);
expect(ctx.threadActions.rollbackThread).toHaveBeenCalledWith("thread-1");
expect(ctx.addSystemMessage).not.toHaveBeenCalledWith("Interrupt the current turn before rolling back.");
});
it("rejects /rollback arguments", async () => {
const ctx = context();
await executeSlashCommand("rollback", "2", ctx);
expect(ctx.threadActions.rollbackThread).not.toHaveBeenCalled();
expect(ctx.addSystemMessage).toHaveBeenCalledWith("/rollback does not take arguments. Usage: /rollback");
});
it("toggles Plan mode without sending text for bare /plan", async () => {
const ctx = context();
@ -512,24 +493,6 @@ describe("slash commands", () => {
expect(result).toBeUndefined();
});
it("rejects /auto-review arguments", async () => {
const ctx = context();
await executeSlashCommand("auto-review", "この依頼からお願いします", ctx);
expect(ctx.runtimeSettings.toggleAutoReview).not.toHaveBeenCalled();
expect(ctx.addSystemMessage).toHaveBeenCalledWith("/auto-review does not take arguments. Usage: /auto-review");
});
it("rejects /compact arguments", async () => {
const ctx = context();
await executeSlashCommand("compact", "ignored for now", ctx);
expect(ctx.threadActions.compactThread).not.toHaveBeenCalled();
expect(ctx.addSystemMessage).toHaveBeenCalledWith("/compact does not take arguments. Usage: /compact");
});
it("rejects bare /archive", async () => {
const ctx = context({ activeThreadId: "active-thread" });
@ -549,35 +512,6 @@ describe("slash commands", () => {
expect(ctx.threadActions.archiveThread).toHaveBeenCalledWith("thread-beta");
});
it("rejects /archive without a thread before active-thread checks", async () => {
const ctx = context({ activeThreadId: null });
await executeSlashCommand("archive", "", ctx);
expect(ctx.threadActions.archiveThread).not.toHaveBeenCalled();
expect(ctx.addSystemMessage).toHaveBeenCalledWith("/archive requires a thread. Usage: /archive <thread>");
});
it("delegates archive running-turn checks to thread actions", async () => {
const ctx = context();
await executeSlashCommand("archive", "thread-1", ctx);
expect(ctx.threadActions.archiveThread).toHaveBeenCalledWith("thread-1");
expect(ctx.addSystemMessage).not.toHaveBeenCalledWith("Finish or interrupt the current turn before archiving threads.");
});
it("reports ambiguous archive matches", async () => {
const ctx = context({
listedThreads: [thread({ id: "thread-alpha", name: "Draft" }), thread({ id: "thread-beta", name: "Draft notes" })],
});
await executeSlashCommand("archive", "Draft", ctx);
expect(ctx.threadActions.archiveThread).not.toHaveBeenCalled();
expect(ctx.addSystemMessage).toHaveBeenCalledWith("Multiple matching threads: Draft (thread-a), Draft notes (thread-b)");
});
it("renames a selected thread by id argument", async () => {
const ctx = context({
listedThreads: [thread({ id: "thread-alpha", name: "Alpha" }), thread({ id: "thread-beta", name: "Beta" })],
@ -607,26 +541,6 @@ describe("slash commands", () => {
expect(ctx.addSystemMessage).toHaveBeenCalledWith("/rename requires a thread and a name. Usage: /rename <thread> <name>");
});
it("rejects blank /rename names after trimming", async () => {
const ctx = context();
await executeSlashCommand("rename", "thread-1 ", ctx);
expect(ctx.threadActions.renameThread).not.toHaveBeenCalled();
expect(ctx.addSystemMessage).toHaveBeenCalledWith("/rename requires a thread and a name. Usage: /rename <thread> <name>");
});
it("reports ambiguous rename matches", async () => {
const ctx = context({
listedThreads: [thread({ id: "thread-alpha", name: "Draft" }), thread({ id: "thread-beta", name: "Draft notes" })],
});
await executeSlashCommand("rename", "Draft New name", ctx);
expect(ctx.threadActions.renameThread).not.toHaveBeenCalled();
expect(ctx.addSystemMessage).toHaveBeenCalledWith("Multiple matching threads: Draft (thread-a), Draft notes (thread-b)");
});
it("shows slash command help as a structured system result", async () => {
const ctx = context();
@ -636,29 +550,6 @@ describe("slash commands", () => {
expect(ctx.addStructuredSystemMessage).toHaveBeenCalledWith("Available slash commands", slashCommandHelpSections());
});
it("groups slash command help by command surface", () => {
const sections = slashCommandHelpSections();
expect(sections.map((section) => section.title)).toEqual(["Panel actions", "Thread settings", "Diagnostics", "Composition"]);
expect(slashCommandHelpKeys("Panel actions")).toEqual(
expect.arrayContaining(["/clear", "/reconnect", "/archive <thread>", "/rename <thread> <name>"]),
);
expect(slashCommandHelpKeys("Thread settings")).toEqual(
expect.arrayContaining(["/plan [message]", "/goal", "/permissions [profile|default]", "/model [model|default]"]),
);
expect(slashCommandHelpKeys("Diagnostics")).toEqual(expect.arrayContaining(["/status", "/doctor", "/tools", "/help"]));
expect(slashCommandHelpKeys("Composition")).toEqual(["/refer <thread> <message>", "/web <url> [message]"]);
});
it("rejects /help arguments", async () => {
const ctx = context();
await executeSlashCommand("help", "anything", ctx);
expect(ctx.addStructuredSystemMessage).not.toHaveBeenCalled();
expect(ctx.addSystemMessage).toHaveBeenCalledWith("/help does not take arguments. Usage: /help");
});
it("shows status as a structured system result", async () => {
const details = [
{
@ -676,15 +567,6 @@ describe("slash commands", () => {
expect(ctx.addStructuredSystemMessage).toHaveBeenCalledWith("Thread status", details);
});
it("rejects /status arguments", async () => {
const ctx = context();
await executeSlashCommand("status", "anything", ctx);
expect(ctx.addStructuredSystemMessage).not.toHaveBeenCalled();
expect(ctx.addSystemMessage).toHaveBeenCalledWith("/status does not take arguments. Usage: /status");
});
it("shows permissions and approvals as shared structured sections", async () => {
const details = [
{ title: "Permissions", auditFacts: [{ key: "Profile", value: "workspace-write" }] },
@ -761,23 +643,6 @@ describe("slash commands", () => {
expect(ctx.addStructuredSystemMessage).toHaveBeenCalledWith("Connection diagnostics", details);
});
it("rejects /doctor arguments", async () => {
const details = [{ title: "Process", rows: [{ key: "connection", value: "connected" }] }];
const ctx = context({ connectionDiagnosticDetails: () => details });
await executeSlashCommand("doctor", "anything", ctx);
expect(ctx.addStructuredSystemMessage).not.toHaveBeenCalled();
expect(ctx.addSystemMessage).toHaveBeenCalledWith("/doctor does not take arguments. Usage: /doctor");
});
it("expands goal subcommands in help", () => {
expect(slashCommandHelpKeys("Thread settings")).toEqual(
expect.arrayContaining(["/goal", "/goal set <objective>", "/goal edit", "/goal pause", "/goal resume", "/goal clear"]),
);
expect(slashCommandHelpKeys("Thread settings")).not.toContain("/goal [set <objective>|edit|pause|resume|clear]");
});
it("rejects unsupported reasoning effort with usage", async () => {
const ctx = context();
@ -869,29 +734,12 @@ describe("slash commands", () => {
]);
});
it("rejects /tools arguments", async () => {
it("toggles fast mode without sending text", async () => {
const ctx = context();
await executeSlashCommand("tools", "install github", ctx);
const result = await executeSlashCommand("fast", "", ctx);
expect(ctx.toolInventoryDetails).not.toHaveBeenCalled();
expect(ctx.addSystemMessage).toHaveBeenCalledWith("/tools does not take arguments. Usage: /tools");
});
it("rejects /fast arguments before toggling", async () => {
const ctx = context();
await executeSlashCommand("fast", "now", ctx);
expect(ctx.runtimeSettings.toggleFastMode).not.toHaveBeenCalled();
expect(ctx.addSystemMessage).toHaveBeenCalledWith("/fast does not take arguments. Usage: /fast");
expect(ctx.runtimeSettings.toggleFastMode).toHaveBeenCalledOnce();
expect(result).toBeUndefined();
});
});
function slashCommandHelpKeys(title: string): string[] {
return (
slashCommandHelpSections()
.find((section) => section.title === title)
?.auditFacts.map((row) => row.key) ?? []
);
}

View file

@ -87,33 +87,6 @@ describe("executeSlashCommandWithState", () => {
expect(result).toBeUndefined();
});
it("routes compact through the shared thread action port", async () => {
const { compactThread, host, stateStore } = createHost();
stateStore.dispatch({
type: "thread-list/applied",
threads: [thread("thread", "Thread")],
});
stateStore.dispatch({
type: "active-thread/resumed",
approvalPolicyKnown: true,
sandboxPolicyKnown: true,
permissionProfileKnown: true,
approvalPolicy: null,
sandboxPolicy: null,
activePermissionProfile: null,
thread: thread("thread", "Thread"),
cwd: "/vault",
model: null,
reasoningEffort: null,
serviceTier: null,
approvalsReviewer: null,
});
await executeSlashCommandWithState(host, "compact", "");
expect(compactThread).toHaveBeenCalledWith("thread");
});
it("routes compact through the shared thread action port before a client is connected", async () => {
const { compactThread, host, stateStore } = createHost({ connectionAvailable: () => false });
stateStore.dispatch({

View file

@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import type { PendingUserInput } from "../../../../../src/domain/pending-requests/model";
import {
pendingRequestFocusSignature,
pendingRequestsSignature,
} from "../../../../../src/features/chat/domain/pending-requests/signatures";
describe("pending request signatures", () => {
it("signs visible request content and sorted drafts deterministically", () => {
const input: PendingUserInput = {
requestId: 7,
params: {
threadId: "thread",
turnId: "turn",
itemId: "item",
questions: [
{
id: "direction",
header: "Direction",
question: "Which way?",
isOther: true,
isSecret: false,
options: [{ label: "Recommended", description: "Use the default path" }],
},
],
autoResolutionMs: null,
},
};
const drafts = new Map([
["z", "last"],
["a", "first"],
]);
expect(pendingRequestsSignature([], [], [], drafts, new Map())).toBe("");
expect(pendingRequestFocusSignature([], [], [])).toBe("");
expect(pendingRequestFocusSignature([], [input], [])).toBe(JSON.stringify({ approvals: [], inputs: [{ id: 7 }], mcpElicitations: [] }));
expect(pendingRequestsSignature([], [input], [], drafts, new Map())).toBe(
JSON.stringify({
approvals: [],
inputs: [
{
id: 7,
questions: [{ id: "direction", header: "Direction", question: "Which way?", options: ["Recommended"] }],
},
],
mcpElicitations: [],
drafts: [
["a", "first"],
["z", "last"],
],
mcpDrafts: [],
}),
);
});
});