Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 83 additions & 2 deletions packages/commit/extensions/commit/register.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -36,12 +37,19 @@ interface CommitResultDetails {
status?: CommitDisplayStatus
}

interface QueuedUserMessage {
text: string
images: NonNullable<InputEvent['images']>
}

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')
Expand All @@ -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
Expand Down Expand Up @@ -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'
)
}
}
}
})
Expand Down
129 changes: 129 additions & 0 deletions packages/commit/test/commit-flow.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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' }
}
])
})