diff --git a/src/constants/toolLimits.ts b/src/constants/toolLimits.ts index c9706316b..83760af92 100644 --- a/src/constants/toolLimits.ts +++ b/src/constants/toolLimits.ts @@ -15,3 +15,5 @@ export const BASH_TRUNCATE_MAX_FILE_BYTES = 1024 * 1024; // 1MB file limit (same export const BASH_MAX_LINE_BYTES = 1024; // 1KB per line for AI agent export const MAX_TODOS = 7; // Maximum number of TODO items in a list + +export const STATUS_MESSAGE_MAX_LENGTH = 60; // Maximum length for status messages (auto-truncated) diff --git a/src/services/tools/status_set.test.ts b/src/services/tools/status_set.test.ts index 36e6d7daf..e8ea73b50 100644 --- a/src/services/tools/status_set.test.ts +++ b/src/services/tools/status_set.test.ts @@ -3,6 +3,7 @@ import { createStatusSetTool } from "./status_set"; import type { ToolConfiguration } from "@/utils/tools/tools"; import { createRuntime } from "@/runtime/runtimeFactory"; import type { ToolCallOptions } from "ai"; +import { STATUS_MESSAGE_MAX_LENGTH } from "@/constants/toolLimits"; describe("status_set tool validation", () => { const mockConfig: ToolConfiguration = { @@ -140,14 +141,15 @@ describe("status_set tool validation", () => { }); describe("message validation", () => { - it("should accept messages up to 40 characters", async () => { + it(`should accept messages up to ${STATUS_MESSAGE_MAX_LENGTH} characters`, async () => { const tool = createStatusSetTool(mockConfig); const result1 = (await tool.execute!( - { emoji: "✅", message: "a".repeat(40) }, + { emoji: "✅", message: "a".repeat(STATUS_MESSAGE_MAX_LENGTH) }, mockToolCallOptions - )) as { success: boolean }; + )) as { success: boolean; message: string }; expect(result1.success).toBe(true); + expect(result1.message).toBe("a".repeat(STATUS_MESSAGE_MAX_LENGTH)); const result2 = (await tool.execute!( { emoji: "✅", message: "Analyzing code structure" }, @@ -156,6 +158,30 @@ describe("status_set tool validation", () => { expect(result2.success).toBe(true); }); + it(`should truncate messages longer than ${STATUS_MESSAGE_MAX_LENGTH} characters with ellipsis`, async () => { + const tool = createStatusSetTool(mockConfig); + + // Test with MAX_LENGTH + 1 characters + const result1 = (await tool.execute!( + { emoji: "✅", message: "a".repeat(STATUS_MESSAGE_MAX_LENGTH + 1) }, + mockToolCallOptions + )) as { success: boolean; message: string }; + expect(result1.success).toBe(true); + expect(result1.message).toBe("a".repeat(STATUS_MESSAGE_MAX_LENGTH - 1) + "…"); + expect(result1.message.length).toBe(STATUS_MESSAGE_MAX_LENGTH); + + // Test with longer message + const longMessage = + "This is a very long message that exceeds the 60 character limit and should be truncated"; + const result2 = (await tool.execute!( + { emoji: "✅", message: longMessage }, + mockToolCallOptions + )) as { success: boolean; message: string }; + expect(result2.success).toBe(true); + expect(result2.message).toBe(longMessage.slice(0, STATUS_MESSAGE_MAX_LENGTH - 1) + "…"); + expect(result2.message.length).toBe(STATUS_MESSAGE_MAX_LENGTH); + }); + it("should accept empty message", async () => { const tool = createStatusSetTool(mockConfig); diff --git a/src/services/tools/status_set.ts b/src/services/tools/status_set.ts index 14a499402..ac0ca36a8 100644 --- a/src/services/tools/status_set.ts +++ b/src/services/tools/status_set.ts @@ -1,6 +1,7 @@ import { tool } from "ai"; import type { ToolFactory } from "@/utils/tools/tools"; import { TOOL_DEFINITIONS } from "@/utils/tools/toolDefinitions"; +import { STATUS_MESSAGE_MAX_LENGTH } from "@/constants/toolLimits"; /** * Result type for status_set tool @@ -38,6 +39,17 @@ function isValidEmoji(str: string): boolean { return emojiRegex.test(segments[0].segment); } +/** + * Truncates a message to a maximum length, adding an ellipsis if truncated + */ +function truncateMessage(message: string, maxLength: number): string { + if (message.length <= maxLength) { + return message; + } + // Truncate to maxLength-1 and add ellipsis (total = maxLength) + return message.slice(0, maxLength - 1) + "…"; +} + /** * Status set tool factory for AI assistant * Creates a tool that allows the AI to set status indicator showing current activity @@ -62,12 +74,15 @@ export const createStatusSetTool: ToolFactory = () => { }); } + // Truncate message if necessary + const truncatedMessage = truncateMessage(message, STATUS_MESSAGE_MAX_LENGTH); + // Tool execution is a no-op on the backend // The status is tracked by StreamingMessageAggregator and displayed in the frontend return Promise.resolve({ success: true, emoji, - message, + message: truncatedMessage, }); }, }); diff --git a/src/utils/messages/StreamingMessageAggregator.status.test.ts b/src/utils/messages/StreamingMessageAggregator.status.test.ts index ad1617688..5609c8b2b 100644 --- a/src/utils/messages/StreamingMessageAggregator.status.test.ts +++ b/src/utils/messages/StreamingMessageAggregator.status.test.ts @@ -494,4 +494,49 @@ describe("StreamingMessageAggregator - Agent Status", () => { // Status should remain undefined (failed validation) expect(aggregator.getAgentStatus()).toBeUndefined(); }); + + it("should use truncated message from output, not original input", () => { + const aggregator = new StreamingMessageAggregator(new Date().toISOString()); + + const messageId = "msg1"; + const toolCallId = "tool1"; + + // Start stream + aggregator.handleStreamStart({ + type: "stream-start", + workspaceId: "workspace1", + messageId, + model: "test-model", + historySequence: 1, + }); + + // Status_set with long message (would be truncated by backend) + const longMessage = "a".repeat(100); // 100 chars, exceeds 60 char limit + const truncatedMessage = "a".repeat(59) + "…"; // What backend returns + + aggregator.handleToolCallStart({ + type: "tool-call-start", + workspaceId: "workspace1", + messageId, + toolCallId, + toolName: "status_set", + args: { emoji: "✅", message: longMessage }, + tokens: 10, + timestamp: Date.now(), + }); + + aggregator.handleToolCallEnd({ + type: "tool-call-end", + workspaceId: "workspace1", + messageId, + toolCallId, + toolName: "status_set", + result: { success: true, emoji: "✅", message: truncatedMessage }, + }); + + // Should use truncated message from output, not the original input + const status = aggregator.getAgentStatus(); + expect(status).toEqual({ emoji: "✅", message: truncatedMessage }); + expect(status?.message.length).toBe(60); + }); }); diff --git a/src/utils/messages/StreamingMessageAggregator.ts b/src/utils/messages/StreamingMessageAggregator.ts index 1af90a9ac..9954b8361 100644 --- a/src/utils/messages/StreamingMessageAggregator.ts +++ b/src/utils/messages/StreamingMessageAggregator.ts @@ -520,9 +520,10 @@ export class StreamingMessageAggregator { } // Update agent status if this was a successful status_set + // Use output instead of input to get the truncated message if (toolName === "status_set" && hasSuccessResult(output)) { - const args = input as { emoji: string; message: string }; - this.agentStatus = { emoji: args.emoji, message: args.message }; + const result = output as { success: true; emoji: string; message: string }; + this.agentStatus = { emoji: result.emoji, message: result.message }; } } diff --git a/src/utils/tools/toolDefinitions.ts b/src/utils/tools/toolDefinitions.ts index 2e106a011..8eea68fe0 100644 --- a/src/utils/tools/toolDefinitions.ts +++ b/src/utils/tools/toolDefinitions.ts @@ -11,6 +11,7 @@ import { BASH_HARD_MAX_LINES, BASH_MAX_LINE_BYTES, BASH_MAX_TOTAL_BYTES, + STATUS_MESSAGE_MAX_LENGTH, } from "@/constants/toolLimits"; import { zodToJsonSchema } from "zod-to-json-schema"; @@ -193,8 +194,9 @@ export const TOOL_DEFINITIONS = { emoji: z.string().describe("A single emoji character representing the current activity"), message: z .string() - .max(40) - .describe("A brief description of the current activity (max 40 characters)"), + .describe( + `A brief description of the current activity (auto-truncated to ${STATUS_MESSAGE_MAX_LENGTH} chars with ellipsis if needed)` + ), }) .strict(), },