diff --git a/src/app-server/routing/server-requests.ts b/src/app-server/routing/server-requests.ts index 777d4f96..1db550bb 100644 --- a/src/app-server/routing/server-requests.ts +++ b/src/app-server/routing/server-requests.ts @@ -33,38 +33,31 @@ export type ServerRequestRoute = | { kind: "inactive"; request: ServerRequest }; type ServerRequestMethod = ServerRequest["method"]; -type ServerRequestRouteKindByMethod = Record>; -type ServerRequestScopeExtractors = { - [Method in ServerRequestMethod]: (request: Extract) => AppServerRouteScope; +type ServerRequestDescriptorByMethod = { + [Method in ServerRequestMethod]: { + routeKind: Exclude; + scope: (request: Extract) => 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; + 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 { diff --git a/src/features/chat/application/composer/slash-commands.ts b/src/features/chat/application/composer/slash-commands.ts index c9a6c59b..7f7b3396 100644 --- a/src/features/chat/application/composer/slash-commands.ts +++ b/src/features/chat/application/composer/slash-commands.ts @@ -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}`); diff --git a/src/features/chat/application/composer/suggestions.ts b/src/features/chat/application/composer/suggestions.ts index e68f613a..d758fd87 100644 --- a/src/features/chat/application/composer/suggestions.ts +++ b/src/features/chat/application/composer/suggestions.ts @@ -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) => ({ diff --git a/src/features/chat/application/turns/slash-command-executor.ts b/src/features/chat/application/turns/slash-command-executor.ts index e1507d76..b70aedcb 100644 --- a/src/features/chat/application/turns/slash-command-executor.ts +++ b/src/features/chat/application/turns/slash-command-executor.ts @@ -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, diff --git a/tests/app-server/protocol/server-requests/approval.test.ts b/tests/app-server/protocol/server-requests/approval.test.ts index 3f605038..4641d339 100644 --- a/tests/app-server/protocol/server-requests/approval.test.ts +++ b/tests/app-server/protocol/server-requests/approval.test.ts @@ -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(value: T | null | undefined): T { if (value === null || value === undefined) throw new Error("Expected value to be present"); return value; } -function approvalActionOptions(approval: NonNullable>) { - 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>): (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 }); diff --git a/tests/app-server/protocol/server-requests/user-input.test.ts b/tests/app-server/protocol/server-requests/user-input.test.ts index f9374a8c..527e228e 100644 --- a/tests/app-server/protocol/server-requests/user-input.test.ts +++ b/tests/app-server/protocol/server-requests/user-input.test.ts @@ -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(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: [], - }), - ); - }); }); diff --git a/tests/features/chat/app-server/inbound/handler.test.ts b/tests/features/chat/app-server/inbound/handler.test.ts index f275c150..973be60c 100644 --- a/tests/features/chat/app-server/inbound/handler.test.ts +++ b/tests/features/chat/app-server/inbound/handler.test.ts @@ -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); - - 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); - - 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; + 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); - - 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["params"]["goal"]; - - const noActiveState = chatStateFixture(); - const noActiveHandler = handlerForState(noActiveState); - noActiveHandler.handleNotification({ - method: "thread/goal/updated", - params: { threadId: "previous-thread", turnId: null, goal }, - } satisfies Extract); - 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); - expect(expectPresent(activeThreadState(otherThreadState)?.goal).objective).toBe("Current"); - }); }); describe("auto-review display", () => { @@ -2305,44 +2030,6 @@ function promptSubmitHookRun(id: string, startedAt: bigint): Extract { + 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["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" }) }, + ]); + }); +}); diff --git a/tests/features/chat/app-server/inbound/routing.test.ts b/tests/features/chat/app-server/inbound/routing.test.ts index 743e54b9..5c3c2669 100644 --- a/tests/features/chat/app-server/inbound/routing.test.ts +++ b/tests/features/chat/app-server/inbound/routing.test.ts @@ -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); diff --git a/tests/features/chat/app-server/inbound/runtime-events.test.ts b/tests/features/chat/app-server/inbound/runtime-events.test.ts index 9b9c71d0..32f8c8d3 100644 --- a/tests/features/chat/app-server/inbound/runtime-events.test.ts +++ b/tests/features/chat/app-server/inbound/runtime-events.test.ts @@ -9,9 +9,9 @@ describe("app-server turn runtime event mapping", () => { params: { threadId: "thread-active", turnId: "turn-active", itemId: "a1", delta: "hello" }, } satisfies Extract; - 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; + + 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"); + }); }); diff --git a/tests/features/chat/app-server/thread-reference-resolver.test.ts b/tests/features/chat/app-server/thread-reference-resolver.test.ts new file mode 100644 index 00000000..e1295909 --- /dev/null +++ b/tests/features/chat/app-server/thread-reference-resolver.test.ts @@ -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)."); + }); +}); diff --git a/tests/features/chat/app-server/transports.test.ts b/tests/features/chat/app-server/transports.test.ts index aa849d5e..46418045 100644 --- a/tests/features/chat/app-server/transports.test.ts +++ b/tests/features/chat/app-server/transports.test.ts @@ -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; diff --git a/tests/features/chat/application/composer/slash-commands.test.ts b/tests/features/chat/application/composer/slash-commands.test.ts new file mode 100644 index 00000000..61bd9bab --- /dev/null +++ b/tests/features/chat/application/composer/slash-commands.test.ts @@ -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 ", "/rename "])); + expect(keys("Thread settings")).toEqual( + expect.arrayContaining(["/plan [message]", "/goal", "/goal set ", "/goal edit", "/permissions [profile|default]"]), + ); + expect(keys("Thread settings")).not.toContain("/goal [set |edit|pause|resume|clear]"); + expect(keys("Diagnostics")).toEqual(expect.arrayContaining(["/status", "/doctor", "/tools", "/help"])); + expect(keys("Composition")).toEqual(["/refer ", "/web [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"]); + }); +}); diff --git a/tests/features/chat/application/threads/thread-management-actions.test.ts b/tests/features/chat/application/threads/thread-management-actions.test.ts index 05c3f2f7..c0b13838 100644 --- a/tests/features/chat/application/threads/thread-management-actions.test.ts +++ b/tests/features/chat/application/threads/thread-management-actions.test.ts @@ -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); diff --git a/tests/features/chat/application/turns/slash-command-execution.test.ts b/tests/features/chat/application/turns/slash-command-execution.test.ts index b9a9a1e4..ff26971d 100644 --- a/tests/features/chat/application/turns/slash-command-execution.test.ts +++ b/tests/features/chat/application/turns/slash-command-execution.test.ts @@ -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 { } 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 "); - }); - - 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 "); }); - 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 "); - }); - - 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 ", "/rename "]), - ); - 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 ", "/web [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 ", "/goal edit", "/goal pause", "/goal resume", "/goal clear"]), - ); - expect(slashCommandHelpKeys("Thread settings")).not.toContain("/goal [set |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) ?? [] - ); -} diff --git a/tests/features/chat/application/turns/slash-command-executor.test.ts b/tests/features/chat/application/turns/slash-command-executor.test.ts index 75b45322..06653a1c 100644 --- a/tests/features/chat/application/turns/slash-command-executor.test.ts +++ b/tests/features/chat/application/turns/slash-command-executor.test.ts @@ -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({ diff --git a/tests/features/chat/domain/pending-requests/signatures.test.ts b/tests/features/chat/domain/pending-requests/signatures.test.ts new file mode 100644 index 00000000..fdd4364a --- /dev/null +++ b/tests/features/chat/domain/pending-requests/signatures.test.ts @@ -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: [], + }), + ); + }); +});