fix(coding-agent): paint optimistic row for idle /skill submits
InputController.#invokeSkillCommand cleared the draft and awaited the full promptCustomMessage dispatch with no transcript render. AgentSession.#promptWithMessage runs awaited preflight (memory recall, before_agent_start hooks, auto-thinking classification, pre-prompt compaction) before the message reaches the agent, so a slow step such as a Hindsight auto-recall timeout left the composer cleared with no pending row, unlike a normal prompt's optimistic row from startPendingSubmission. Idle skill submissions now paint an optimistic skill row before the awaited dispatch; the canonical message_start reconciles it in place via EventController instead of appending a duplicate. Streaming submissions still queue and show their chip. Fixes #8895
This commit is contained in:
@@ -89,6 +89,9 @@ function createStubInputControllerContext(opts: {
|
||||
const updatePendingMessagesDisplay = vi.fn();
|
||||
const requestRender = vi.fn();
|
||||
const showError = vi.fn();
|
||||
const renderOptimisticSkillMessage = vi.fn();
|
||||
const reconcileOptimisticSkillMessage = vi.fn();
|
||||
const clearOptimisticSkillMessage = vi.fn();
|
||||
const queueCompactionMessage = vi.fn((_text: string, _mode: "steer" | "followUp", _images?: ImageContent[]) => {});
|
||||
const ctx = {
|
||||
editor,
|
||||
@@ -117,6 +120,10 @@ function createStubInputControllerContext(opts: {
|
||||
locallySubmittedUserSignatures: new Set<string>(),
|
||||
withLocalSubmission: async (_text: string, fn: () => unknown) => fn(),
|
||||
queueCompactionMessage,
|
||||
optimisticSkillMessagePending: false,
|
||||
renderOptimisticSkillMessage,
|
||||
reconcileOptimisticSkillMessage,
|
||||
clearOptimisticSkillMessage,
|
||||
} as unknown as InteractiveModeContext;
|
||||
|
||||
return {
|
||||
@@ -128,6 +135,10 @@ function createStubInputControllerContext(opts: {
|
||||
updatePendingMessagesDisplay,
|
||||
requestRender,
|
||||
queueCompactionMessage,
|
||||
showError,
|
||||
renderOptimisticSkillMessage,
|
||||
reconcileOptimisticSkillMessage,
|
||||
clearOptimisticSkillMessage,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -259,6 +270,83 @@ describe("InputController skill queue chip metadata", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("InputController optimistic skill row (#8895)", () => {
|
||||
let tempDir: TempDir;
|
||||
let skillCommands: Map<string, Skill>;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = TempDir.createSync("@pi-skill-optimistic-stub-");
|
||||
const skill = await writeSkillFile(tempDir.path(), "test-skill", "Do the thing.");
|
||||
skillCommands = new Map<string, Skill>([["skill:test-skill", skill]]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
tempDir.removeSync();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("paints the optimistic row before dispatching the idle skill turn", async () => {
|
||||
const { ctx, editor, promptCustomMessage, renderOptimisticSkillMessage } = createStubInputControllerContext({
|
||||
skillCommands,
|
||||
isStreaming: false,
|
||||
});
|
||||
// A slow preflight (memory recall, before_agent_start hooks, auto-thinking)
|
||||
// lives inside promptCustomMessage; the row must already be painted when the
|
||||
// dispatch begins, so it stays visible while that preflight runs.
|
||||
const order: string[] = [];
|
||||
renderOptimisticSkillMessage.mockImplementation(() => order.push("render"));
|
||||
promptCustomMessage.mockImplementation(async () => {
|
||||
order.push("dispatch");
|
||||
});
|
||||
|
||||
const controller = new InputController(ctx);
|
||||
controller.setupEditorSubmitHandler();
|
||||
editor.setText("/skill:test-skill go");
|
||||
await editor.onSubmit?.("/skill:test-skill go");
|
||||
|
||||
expect(order).toEqual(["render", "dispatch"]);
|
||||
expect(renderOptimisticSkillMessage.mock.calls[0]?.[0]).toMatchObject({
|
||||
role: "custom",
|
||||
customType: SKILL_PROMPT_MESSAGE_TYPE,
|
||||
attribution: "user",
|
||||
display: true,
|
||||
details: { name: "test-skill" },
|
||||
});
|
||||
});
|
||||
|
||||
it("skips the optimistic row when the skill submission queues while streaming", async () => {
|
||||
const { ctx, editor, promptCustomMessage, renderOptimisticSkillMessage } = createStubInputControllerContext({
|
||||
skillCommands,
|
||||
isStreaming: true,
|
||||
});
|
||||
const controller = new InputController(ctx);
|
||||
controller.setupEditorSubmitHandler();
|
||||
editor.setText("/skill:test-skill go");
|
||||
await editor.onSubmit?.("/skill:test-skill go");
|
||||
|
||||
expect(renderOptimisticSkillMessage).not.toHaveBeenCalled();
|
||||
expect(promptCustomMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("drops the optimistic row and restores the draft when dispatch throws", async () => {
|
||||
const { ctx, editor, promptCustomMessage, renderOptimisticSkillMessage, clearOptimisticSkillMessage, showError } =
|
||||
createStubInputControllerContext({ skillCommands, isStreaming: false });
|
||||
promptCustomMessage.mockImplementation(async () => {
|
||||
throw new Error("preflight failed");
|
||||
});
|
||||
|
||||
const controller = new InputController(ctx);
|
||||
controller.setupEditorSubmitHandler();
|
||||
editor.setText("/skill:test-skill go");
|
||||
await editor.onSubmit?.("/skill:test-skill go");
|
||||
|
||||
expect(renderOptimisticSkillMessage).toHaveBeenCalledTimes(1);
|
||||
expect(clearOptimisticSkillMessage).toHaveBeenCalledTimes(1);
|
||||
expect(showError).toHaveBeenCalledTimes(1);
|
||||
expect(editor.getText()).toBe("/skill:test-skill go");
|
||||
});
|
||||
});
|
||||
|
||||
describe("compaction skill re-invocation", () => {
|
||||
let tempDir: TempDir;
|
||||
let skillCommands: Map<string, Skill>;
|
||||
@@ -761,10 +849,11 @@ describe("UiHelpers / InputController against derived queued custom display", ()
|
||||
});
|
||||
});
|
||||
|
||||
function createEventControllerFixture() {
|
||||
function createEventControllerFixture(opts?: { optimisticSkillMessagePending?: boolean }) {
|
||||
const updatePendingMessagesDisplay = vi.fn();
|
||||
const addMessageToChat = vi.fn();
|
||||
const requestRender = vi.fn();
|
||||
const reconcileOptimisticSkillMessage = vi.fn();
|
||||
const ctx = {
|
||||
isInitialized: true,
|
||||
init: vi.fn(async () => {}),
|
||||
@@ -776,13 +865,15 @@ function createEventControllerFixture() {
|
||||
transcriptMessageComponents: new WeakMap(),
|
||||
pendingTools: new Map(),
|
||||
session: {},
|
||||
optimisticSkillMessagePending: opts?.optimisticSkillMessagePending ?? false,
|
||||
reconcileOptimisticSkillMessage,
|
||||
get viewSession() {
|
||||
return (this as typeof ctx).session;
|
||||
},
|
||||
} as unknown as InteractiveModeContext;
|
||||
|
||||
const controller = new EventController(ctx);
|
||||
return { controller, updatePendingMessagesDisplay, addMessageToChat };
|
||||
return { controller, updatePendingMessagesDisplay, addMessageToChat, reconcileOptimisticSkillMessage };
|
||||
}
|
||||
|
||||
describe("EventController custom queued-message refresh", () => {
|
||||
@@ -829,4 +920,49 @@ describe("EventController custom queued-message refresh", () => {
|
||||
expect(updatePendingMessagesDisplay).toHaveBeenCalledTimes(1);
|
||||
expect(addMessageToChat).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("reconciles the optimistic skill row instead of duplicating it on the canonical message_start", async () => {
|
||||
const { controller, addMessageToChat, reconcileOptimisticSkillMessage } = createEventControllerFixture({
|
||||
optimisticSkillMessagePending: true,
|
||||
});
|
||||
const event: Extract<AgentSessionEvent, { type: "message_start" }> = {
|
||||
type: "message_start",
|
||||
message: {
|
||||
role: "custom",
|
||||
customType: SKILL_PROMPT_MESSAGE_TYPE,
|
||||
content: "skill body",
|
||||
display: true,
|
||||
attribution: "user",
|
||||
details: { name: "test-skill", path: "/s.md", lineCount: 1 } satisfies SkillPromptDetails,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
};
|
||||
await controller.handleEvent(event);
|
||||
|
||||
expect(reconcileOptimisticSkillMessage).toHaveBeenCalledTimes(1);
|
||||
expect(reconcileOptimisticSkillMessage.mock.calls[0]?.[0]).toBe(event.message);
|
||||
expect(addMessageToChat).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders normally when no optimistic skill row is pending", async () => {
|
||||
const { controller, addMessageToChat, reconcileOptimisticSkillMessage } = createEventControllerFixture({
|
||||
optimisticSkillMessagePending: false,
|
||||
});
|
||||
const event: Extract<AgentSessionEvent, { type: "message_start" }> = {
|
||||
type: "message_start",
|
||||
message: {
|
||||
role: "custom",
|
||||
customType: SKILL_PROMPT_MESSAGE_TYPE,
|
||||
content: "skill body",
|
||||
display: true,
|
||||
attribution: "user",
|
||||
details: { name: "test-skill", path: "/s.md", lineCount: 1 } satisfies SkillPromptDetails,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
};
|
||||
await controller.handleEvent(event);
|
||||
|
||||
expect(reconcileOptimisticSkillMessage).not.toHaveBeenCalled();
|
||||
expect(addMessageToChat).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user