Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.

Commit ef66ce6

Browse files
committed
fix: queued messages with only images now properly sent to UI and LLM
Fixes EXT-639 where queued messages containing only images (no text) would vanish after being dequeued without being shown in the UI or sent to the LLM. Root cause: processQueuedMessages called submitUserMessage which sets askResponse values, but those values are only consumed when there is a pending ask(). When called after tool execution (no pending ask), the content was lost. Fix: Changed processQueuedMessages to directly: 1. Show user feedback in UI via say("user_feedback", ...) 2. Add content to userMessageContent array for the next LLM request 3. Handle image-only messages with hasText || hasImages check Added test to verify image-only queued messages work correctly.
1 parent 17d3456 commit ef66ce6

2 files changed

Lines changed: 82 additions & 26 deletions

File tree

src/core/task/Task.ts

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4663,22 +4663,41 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
46634663
}
46644664

46654665
/**
4666-
* Process any queued messages by dequeuing and submitting them.
4667-
* This ensures that queued user messages are sent when appropriate,
4668-
* preventing them from getting stuck in the queue.
4666+
* Process any queued messages by dequeuing and adding them to the current
4667+
* user message content. This ensures that queued user messages are included
4668+
* in the next LLM request, preventing them from getting stuck in the queue.
46694669
*
4670-
* @param context - Context string for logging (e.g., the calling tool name)
4670+
* Unlike submitUserMessage (which sets askResponse values for pending asks),
4671+
* this method directly adds content to userMessageContent, which is appropriate
4672+
* when called after tool execution when there's no pending ask.
46714673
*/
46724674
public processQueuedMessages(): void {
46734675
try {
46744676
if (!this.messageQueueService.isEmpty()) {
46754677
const queued = this.messageQueueService.dequeueMessage()
46764678
if (queued) {
4677-
setTimeout(() => {
4678-
this.submitUserMessage(queued.text, queued.images).catch((err) =>
4679-
console.error(`[Task] Failed to submit queued message:`, err),
4679+
const text = (queued.text ?? "").trim()
4680+
const images = queued.images ?? []
4681+
const hasText = text.length > 0
4682+
const hasImages = images.length > 0
4683+
4684+
if (hasText || hasImages) {
4685+
// Show user feedback in the UI
4686+
this.say("user_feedback", queued.text, queued.images).catch((err) =>
4687+
console.error(`[Task] Failed to show queued message feedback:`, err),
46804688
)
4681-
}, 0)
4689+
4690+
// Add to userMessageContent for the next LLM request
4691+
if (hasText) {
4692+
this.userMessageContent.push({
4693+
type: "text",
4694+
text: `<user_message>\n${text}\n</user_message>`,
4695+
})
4696+
}
4697+
if (hasImages) {
4698+
this.userMessageContent.push(...formatResponse.imageBlocks(images))
4699+
}
4700+
}
46824701
}
46834702
}
46844703
} catch (e) {

src/core/task/__tests__/Task.spec.ts

Lines changed: 55 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1861,20 +1861,63 @@ describe("Queued message processing after condense", () => {
18611861

18621862
// Make condense fast + deterministic
18631863
vi.spyOn(task as any, "getSystemPrompt").mockResolvedValue("system")
1864-
const submitSpy = vi.spyOn(task, "submitUserMessage").mockResolvedValue(undefined)
1864+
const saySpy = vi.spyOn(task, "say").mockResolvedValue(undefined)
18651865

18661866
// Queue a message during condensing
18671867
task.messageQueueService.addMessage("queued text", ["img1.png"])
18681868

1869-
// Use fake timers to capture setTimeout(0) in processQueuedMessages
1870-
vi.useFakeTimers()
18711869
await task.condenseContext()
18721870

1873-
// Flush the microtask that submits the queued message
1874-
vi.runAllTimers()
1875-
vi.useRealTimers()
1871+
// Verify the message was shown in UI
1872+
expect(saySpy).toHaveBeenCalledWith("user_feedback", "queued text", ["img1.png"])
18761873

1877-
expect(submitSpy).toHaveBeenCalledWith("queued text", ["img1.png"])
1874+
// Verify the content was added to userMessageContent
1875+
expect(task.userMessageContent.length).toBeGreaterThan(0)
1876+
const textBlock = task.userMessageContent.find(
1877+
(block) => block.type === "text" && (block as any).text?.includes("queued text"),
1878+
)
1879+
expect(textBlock).toBeDefined()
1880+
1881+
// Verify queue was emptied
1882+
expect(task.messageQueueService.isEmpty()).toBe(true)
1883+
})
1884+
1885+
it("processes image-only queued messages correctly", async () => {
1886+
const provider = createProvider()
1887+
const task = new Task({
1888+
provider,
1889+
apiConfiguration: apiConfig,
1890+
task: "initial task",
1891+
startTask: false,
1892+
})
1893+
1894+
vi.spyOn(task as any, "getSystemPrompt").mockResolvedValue("system")
1895+
const saySpy = vi.spyOn(task, "say").mockResolvedValue(undefined)
1896+
1897+
// Queue a message with ONLY images (no text) - this is the bug scenario
1898+
// Images must be in data URL format for formatResponse.imageBlocks to parse them correctly
1899+
const testImages = [
1900+
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
1901+
"data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRof",
1902+
]
1903+
task.messageQueueService.addMessage("", testImages)
1904+
1905+
await task.condenseContext()
1906+
1907+
// Verify the message was shown in UI (with empty text but images)
1908+
expect(saySpy).toHaveBeenCalledWith("user_feedback", "", testImages)
1909+
1910+
// Verify image blocks were added to userMessageContent
1911+
const imageBlocks = task.userMessageContent.filter((block) => block.type === "image")
1912+
expect(imageBlocks.length).toBe(2)
1913+
1914+
// Verify no text block was added for empty text
1915+
const textBlocks = task.userMessageContent.filter(
1916+
(block) => block.type === "text" && (block as any).text?.includes("<user_message>"),
1917+
)
1918+
expect(textBlocks.length).toBe(0)
1919+
1920+
// Verify queue was emptied
18781921
expect(task.messageQueueService.isEmpty()).toBe(true)
18791922
})
18801923

@@ -1898,29 +1941,23 @@ describe("Queued message processing after condense", () => {
18981941
vi.spyOn(taskA as any, "getSystemPrompt").mockResolvedValue("system")
18991942
vi.spyOn(taskB as any, "getSystemPrompt").mockResolvedValue("system")
19001943

1901-
const spyA = vi.spyOn(taskA, "submitUserMessage").mockResolvedValue(undefined)
1902-
const spyB = vi.spyOn(taskB, "submitUserMessage").mockResolvedValue(undefined)
1944+
const saySpyA = vi.spyOn(taskA, "say").mockResolvedValue(undefined)
1945+
const saySpyB = vi.spyOn(taskB, "say").mockResolvedValue(undefined)
19031946

19041947
taskA.messageQueueService.addMessage("A message")
19051948
taskB.messageQueueService.addMessage("B message")
19061949

19071950
// Condense in task A should only drain A's queue
1908-
vi.useFakeTimers()
19091951
await taskA.condenseContext()
1910-
vi.runAllTimers()
1911-
vi.useRealTimers()
19121952

1913-
expect(spyA).toHaveBeenCalledWith("A message", undefined)
1914-
expect(spyB).not.toHaveBeenCalled()
1953+
expect(saySpyA).toHaveBeenCalledWith("user_feedback", "A message", undefined)
1954+
expect(saySpyB).not.toHaveBeenCalledWith("user_feedback", expect.anything(), expect.anything())
19151955
expect(taskB.messageQueueService.isEmpty()).toBe(false)
19161956

19171957
// Now condense in task B should drain B's queue
1918-
vi.useFakeTimers()
19191958
await taskB.condenseContext()
1920-
vi.runAllTimers()
1921-
vi.useRealTimers()
19221959

1923-
expect(spyB).toHaveBeenCalledWith("B message", undefined)
1960+
expect(saySpyB).toHaveBeenCalledWith("user_feedback", "B message", undefined)
19241961
expect(taskB.messageQueueService.isEmpty()).toBe(true)
19251962
})
19261963
})

0 commit comments

Comments
 (0)