diff --git a/packages/commit/extensions/commit/register.ts b/packages/commit/extensions/commit/register.ts index 996459a..cf1275a 100644 --- a/packages/commit/extensions/commit/register.ts +++ b/packages/commit/extensions/commit/register.ts @@ -1,6 +1,7 @@ import type { ExtensionAPI, - ExtensionCommandContext + ExtensionCommandContext, + InputEvent } from '@earendil-works/pi-coding-agent' import { getMarkdownTheme } from '@earendil-works/pi-coding-agent' import { Container, Markdown, Spacer, Text } from '@earendil-works/pi-tui' @@ -36,12 +37,19 @@ interface CommitResultDetails { status?: CommitDisplayStatus } +interface QueuedUserMessage { + text: string + images: NonNullable +} + interface CommitRuntime { cachedConfig: ResolvedCommitConfig commitCommandPending: boolean commitWorkerRunning: boolean agentEndWaiter?: (messages: unknown[]) => void latestCommitWorkerMessages?: unknown[] + queuedUserMessages: QueuedUserMessage[] + queuedFollowUpUserMessages: QueuedUserMessage[] } const RUNTIME_REGISTRY_KEY = Symbol.for('pi-commit.runtime.v1') @@ -65,10 +73,67 @@ export function registerCommit(pi: ExtensionAPI): void { const runtime: CommitRuntime = { cachedConfig: DEFAULT_COMMIT_CONFIG, commitCommandPending: false, - commitWorkerRunning: false + commitWorkerRunning: false, + queuedUserMessages: [], + queuedFollowUpUserMessages: [] } registry.set(runtimeKey, runtime) + const sendQueuedUserMessage = ( + message: QueuedUserMessage, + deliverAs?: 'steer' | 'followUp' + ) => { + const content = + message.images.length === 0 + ? message.text + : [{ type: 'text' as const, text: message.text }, ...message.images] + + if (deliverAs) { + pi.sendUserMessage(content, { deliverAs }) + } else { + pi.sendUserMessage(content) + } + } + + const releaseQueuedUserMessages = () => { + const [firstMessage, ...followUpMessages] = + runtime.queuedUserMessages.splice(0) + if (!firstMessage) return + + runtime.queuedFollowUpUserMessages.push(...followUpMessages) + sendQueuedUserMessage(firstMessage) + } + + pi.on('input', (event, ctx) => { + if (!runtime.commitWorkerRunning || event.source === 'extension') { + return { action: 'continue' } + } + + runtime.queuedUserMessages.push({ + text: event.text, + images: event.images ? [...event.images] : [] + }) + ctx.ui.notify( + `Message queued until the commit worker exits (${runtime.queuedUserMessages.length} pending)`, + 'info' + ) + return { action: 'handled' } + }) + + pi.on('agent_start', () => { + if ( + runtime.commitWorkerRunning || + runtime.queuedFollowUpUserMessages.length === 0 + ) { + return + } + + const followUpMessages = runtime.queuedFollowUpUserMessages.splice(0) + for (const message of followUpMessages) { + sendQueuedUserMessage(message, 'followUp') + } + }) + pi.on('agent_end', (event) => { if (runtime.commitWorkerRunning) { runtime.latestCommitWorkerMessages = event.messages @@ -369,6 +434,22 @@ export function registerCommit(pi: ExtensionAPI): void { ctx.ui.setWidget(COMMIT_WORKER_WIDGET_KEY, undefined) runtime.commitWorkerRunning = false runtime.latestCommitWorkerMessages = undefined + + const returnedToSourceBranch = + Boolean(startLeafId) && ctx.sessionManager.getLeafId() === startLeafId + if (returnedToSourceBranch && runtime.queuedUserMessages.length > 0) { + const queuedMessageCount = runtime.queuedUserMessages.length + ctx.ui.notify( + `Commit worker exited; releasing ${queuedMessageCount} queued message${queuedMessageCount === 1 ? '' : 's'}`, + 'info' + ) + releaseQueuedUserMessages() + } else if (runtime.queuedUserMessages.length > 0) { + ctx.ui.notify( + `${runtime.queuedUserMessages.length} message${runtime.queuedUserMessages.length === 1 ? ' remains' : 's remain'} queued because the commit worker branch could not be exited`, + 'warning' + ) + } } } }) diff --git a/packages/commit/test/commit-flow.test.mjs b/packages/commit/test/commit-flow.test.mjs index 700ed56..34b56e7 100644 --- a/packages/commit/test/commit-flow.test.mjs +++ b/packages/commit/test/commit-flow.test.mjs @@ -166,3 +166,132 @@ test('/commit selects immediately, then waits for the active turn', async () => ) ) }) + +test('/commit queues user input until it returns to the source branch', async () => { + const handlers = new Map() + const notifications = [] + const sentUserMessages = [] + let command + let leafId = 'source-leaf' + let workerStartedResolve + const workerStarted = new Promise((resolve) => { + workerStartedResolve = resolve + }) + + const pi = { + on(name, handler) { + handlers.set(name, [...(handlers.get(name) ?? []), handler]) + }, + registerCommand(name, registered) { + if (name === 'commit') command = registered + }, + registerMessageRenderer() {}, + sendMessage(message) { + if (message.customType === PROMPT_MESSAGE_TYPE) { + leafId = 'worker-leaf' + workerStartedResolve() + } + }, + sendUserMessage(content, options) { + sentUserMessages.push({ content, options }) + } + } + + const ctx = { + mode: 'json', + isIdle() { + return true + }, + waitForIdle() { + return Promise.resolve() + }, + sessionManager: { + getLeafId() { + return leafId + } + }, + async navigateTree(targetLeafId) { + leafId = targetLeafId + return { cancelled: false } + }, + ui: { + async select() { + throw new Error('explicit change types should not open the selector') + }, + notify(message, type) { + notifications.push({ message, type }) + }, + setWidget() {} + } + } + + piCommitExtension(pi) + assert.ok(command, '/commit command should be registered') + + const commandPromise = command.handler('fix queue user input', ctx) + await workerStarted + + const image = { type: 'image', data: 'image-data', mimeType: 'image/png' } + const firstResult = await handlers.get('input')[0]( + { + text: 'First queued request', + images: [image], + source: 'interactive', + streamingBehavior: 'steer' + }, + ctx + ) + const secondResult = await handlers.get('input')[0]( + { + text: 'Second queued request', + source: 'interactive', + streamingBehavior: 'followUp' + }, + ctx + ) + + assert.deepEqual(firstResult, { action: 'handled' }) + assert.deepEqual(secondResult, { action: 'handled' }) + assert.deepEqual(sentUserMessages, []) + assert.equal(leafId, 'worker-leaf') + + for (const handler of handlers.get('agent_end') ?? []) { + await handler({ + messages: [ + { customType: PROMPT_MESSAGE_TYPE }, + { + role: 'assistant', + content: [{ type: 'text', text: 'status: committed' }] + } + ] + }) + } + await commandPromise + + assert.equal(leafId, 'source-leaf') + assert.deepEqual(sentUserMessages, [ + { + content: [{ type: 'text', text: 'First queued request' }, image], + options: undefined + } + ]) + assert.ok( + notifications.some(({ message }) => + message.includes('releasing 2 queued messages') + ) + ) + + for (const handler of handlers.get('agent_start') ?? []) { + await handler({}, ctx) + } + assert.deepEqual(sentUserMessages, [ + { + content: [{ type: 'text', text: 'First queued request' }, image], + options: undefined + }, + { + content: 'Second queued request', + options: { deliverAs: 'followUp' } + } + ]) +})