mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
refactor(test): consolidate command and protocol contracts
This commit is contained in:
parent
c37832d0a3
commit
86fc06998b
17 changed files with 430 additions and 713 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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) => ({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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" }) },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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).");
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) ?? []
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue