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
2 changes: 2 additions & 0 deletions src/constants/toolLimits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
32 changes: 29 additions & 3 deletions src/services/tools/status_set.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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" },
Expand All @@ -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);

Expand Down
17 changes: 16 additions & 1 deletion src/services/tools/status_set.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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,
});
},
});
Expand Down
45 changes: 45 additions & 0 deletions src/utils/messages/StreamingMessageAggregator.status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
5 changes: 3 additions & 2 deletions src/utils/messages/StreamingMessageAggregator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
}

Expand Down
6 changes: 4 additions & 2 deletions src/utils/tools/toolDefinitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(),
},
Expand Down