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
6 changes: 6 additions & 0 deletions src/browser/stores/WorkspaceStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -476,10 +476,16 @@ export class WorkspaceStore {

// Get last message's context usage for context window display
// Uses contextUsage (last step) if available, falls back to usage for old messages
// Skips compacted messages - their usage reflects pre-compaction context, not current
const lastContextUsage = (() => {
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (msg.role === "assistant") {
// Skip compacted messages - their usage is from pre-compaction context
// and doesn't reflect current context window size
if (msg.metadata?.compacted) {
continue;
}
const rawUsage = msg.metadata?.contextUsage ?? msg.metadata?.usage;
const providerMeta =
msg.metadata?.contextProviderMetadata ?? msg.metadata?.providerMetadata;
Expand Down
71 changes: 61 additions & 10 deletions src/node/services/messageQueue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ describe("MessageQueue", () => {
expect(queue.getDisplayText()).toBe("/compact -t 3000");
});

it("should show actual messages when compaction is added after normal message", () => {
it("should throw when adding compaction after normal message", () => {
queue.add("First message");

const metadata: MuxFrontendMetadata = {
Expand All @@ -49,11 +49,11 @@ describe("MessageQueue", () => {
muxMetadata: metadata,
};

queue.add("Summarize this conversation...", options);

// When multiple messages are queued, compaction metadata is lost when sent,
// so display shows actual messages (not rawCommand) to match what will be sent
expect(queue.getDisplayText()).toBe("First message\nSummarize this conversation...");
// Compaction requests cannot be mixed with other messages to prevent
// silent failures where compaction metadata would be lost
expect(() => queue.add("Summarize this conversation...", options)).toThrow(
/Cannot queue compaction request/
);
});

it("should return joined messages when metadata type is not compaction-request", () => {
Expand Down Expand Up @@ -117,6 +117,48 @@ describe("MessageQueue", () => {
});
});

describe("hasCompactionRequest", () => {
it("should return false for empty queue", () => {
expect(queue.hasCompactionRequest()).toBe(false);
});

it("should return false for normal messages", () => {
queue.add("Regular message", { model: "gpt-4" });
expect(queue.hasCompactionRequest()).toBe(false);
});

it("should return true when compaction request is queued", () => {
const metadata: MuxFrontendMetadata = {
type: "compaction-request",
rawCommand: "/compact",
parsed: {},
};

queue.add("Summarize...", {
model: "claude-3-5-sonnet-20241022",
muxMetadata: metadata,
});

expect(queue.hasCompactionRequest()).toBe(true);
});

it("should return false after clearing", () => {
const metadata: MuxFrontendMetadata = {
type: "compaction-request",
rawCommand: "/compact",
parsed: {},
};

queue.add("Summarize...", {
model: "claude-3-5-sonnet-20241022",
muxMetadata: metadata,
});
queue.clear();

expect(queue.hasCompactionRequest()).toBe(false);
});
});

describe("multi-message batching", () => {
it("should batch multiple follow-up messages", () => {
queue.add("First message");
Expand All @@ -127,7 +169,7 @@ describe("MessageQueue", () => {
expect(queue.getDisplayText()).toBe("First message\nSecond message\nThird message");
});

it("should batch follow-up message after compaction", () => {
it("should preserve compaction metadata when follow-up is added", () => {
const metadata: MuxFrontendMetadata = {
type: "compaction-request",
rawCommand: "/compact",
Expand All @@ -140,11 +182,20 @@ describe("MessageQueue", () => {
});
queue.add("And then do this follow-up task");

// When a follow-up is added, compaction metadata is lost (latestOptions overwritten),
// so display shows actual messages to match what will be sent
// Display shows all messages (multiple messages = not just compaction)
expect(queue.getDisplayText()).toBe("Summarize...\nAnd then do this follow-up task");
// Raw messages have the actual prompt

// getMessages includes both
expect(queue.getMessages()).toEqual(["Summarize...", "And then do this follow-up task"]);

// produceMessage preserves compaction metadata from first message
const { message, options } = queue.produceMessage();
expect(message).toBe("Summarize...\nAnd then do this follow-up task");
const muxMeta = options?.muxMetadata as MuxFrontendMetadata;
expect(muxMeta.type).toBe("compaction-request");
if (muxMeta.type === "compaction-request") {
expect(muxMeta.rawCommand).toBe("/compact");
}
});

it("should produce combined message for API call", () => {
Expand Down
57 changes: 46 additions & 11 deletions src/node/services/messageQueue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,22 +17,37 @@ function isCompactionMetadata(meta: unknown): meta is CompactionMetadata {
*
* Stores:
* - Message texts (accumulated)
* - Latest options (model, thinking level, etc. - overwrites on each add)
* - First muxMetadata (preserved - never overwritten by subsequent adds)
* - Latest options (model, etc. - updated on each add)
* - Image parts (accumulated across all messages)
*
* IMPORTANT: muxMetadata from the first message is preserved even when
* subsequent messages are added. This prevents compaction requests from
* losing their metadata when follow-up messages are queued.
*
* Display logic:
* - Single compaction request → shows rawCommand (/compact)
* - Multiple messages → shows all actual message texts (since compaction metadata is lost anyway)
* - Multiple messages → shows all actual message texts
*/
export class MessageQueue {
private messages: string[] = [];
private firstMuxMetadata?: unknown;
private latestOptions?: SendMessageOptions;
private accumulatedImages: ImagePart[] = [];

/**
* Check if the queue currently contains a compaction request.
*/
hasCompactionRequest(): boolean {
return isCompactionMetadata(this.firstMuxMetadata);
}

/**
* Add a message to the queue.
* Updates to latest options, accumulates image parts.
* Allows image-only messages (empty text with images).
* Preserves muxMetadata from first message, updates other options.
* Accumulates image parts.
*
* @throws Error if trying to add a compaction request when queue already has messages
*/
add(message: string, options?: SendMessageOptions & { imageParts?: ImagePart[] }): void {
const trimmedMessage = message.trim();
Expand All @@ -43,13 +58,30 @@ export class MessageQueue {
return;
}

const incomingIsCompaction = isCompactionMetadata(options?.muxMetadata);
const queueHasMessages = !this.isEmpty();

// Cannot add compaction to a queue that already has messages
// (user should wait for those messages to send first)
if (incomingIsCompaction && queueHasMessages) {
throw new Error(
"Cannot queue compaction request: queue already has messages. " +
"Wait for current stream to complete before compacting."
);
}

// Add text message if non-empty
if (trimmedMessage.length > 0) {
this.messages.push(trimmedMessage);
}

if (options) {
const { imageParts, ...restOptions } = options;

// Preserve first muxMetadata (see class docblock for rationale)
if (options.muxMetadata !== undefined && this.firstMuxMetadata === undefined) {
this.firstMuxMetadata = options.muxMetadata;
}
this.latestOptions = restOptions;

if (imageParts && imageParts.length > 0) {
Expand All @@ -68,14 +100,12 @@ export class MessageQueue {
/**
* Get display text for queued messages.
* - Single compaction request shows rawCommand (/compact)
* - Multiple messages or non-compaction show actual message texts
* - Multiple messages show all actual message texts
*/
getDisplayText(): string {
// Only show rawCommand for single compaction request
// (compaction metadata is only preserved when no follow-up messages are added)
const muxMetadata = this.latestOptions?.muxMetadata as unknown;
if (this.messages.length === 1 && isCompactionMetadata(muxMetadata)) {
return muxMetadata.rawCommand;
if (this.messages.length === 1 && isCompactionMetadata(this.firstMuxMetadata)) {
return this.firstMuxMetadata.rawCommand;
}

return this.messages.join("\n");
Expand All @@ -90,17 +120,21 @@ export class MessageQueue {

/**
* Get combined message and options for sending.
* Returns joined messages with latest options + accumulated images.
*/
produceMessage(): {
message: string;
options?: SendMessageOptions & { imageParts?: ImagePart[] };
} {
const joinedMessages = this.messages.join("\n");

// First metadata takes precedence (preserves compaction requests)
const muxMetadata =
this.firstMuxMetadata !== undefined
? this.firstMuxMetadata
: (this.latestOptions?.muxMetadata as unknown);
const options = this.latestOptions
? {
...this.latestOptions,
muxMetadata,
imageParts: this.accumulatedImages.length > 0 ? this.accumulatedImages : undefined,
}
: undefined;
Expand All @@ -113,6 +147,7 @@ export class MessageQueue {
*/
clear(): void {
this.messages = [];
this.firstMuxMetadata = undefined;
this.latestOptions = undefined;
this.accumulatedImages = [];
}
Expand Down