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
4 changes: 4 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,10 @@ export default defineConfig([
},
{
// Frontend architectural boundary - prevent services and tokenizer imports
// Note: src/utils/** and src/stores/** are not included because:
// - Some utils are shared between main/renderer (e.g., utils/tools registry)
// - Stores can import from utils/messages which is renderer-safe
// - Type-only imports from services are safe (types live in src/types/)
files: ["src/components/**", "src/contexts/**", "src/hooks/**", "src/App.tsx"],
rules: {
"no-restricted-imports": [
Expand Down
17 changes: 16 additions & 1 deletion src/components/AgentStatusIndicator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,14 +74,29 @@ export const AgentStatusIndicator: React.FC<AgentStatusIndicatorProps> = ({
/>
);

const handleEmojiClick = useCallback(
(e: React.MouseEvent) => {
if (agentStatus?.url) {
e.stopPropagation(); // Prevent workspace selection
window.open(agentStatus.url, "_blank", "noopener,noreferrer");
}
},
[agentStatus?.url]
);

const emoji = agentStatus ? (
<div
className="flex shrink-0 items-center justify-center transition-all duration-200"
className={cn(
"flex shrink-0 items-center justify-center transition-all duration-200",
agentStatus.url && "cursor-pointer hover:opacity-80"
)}
style={{
fontSize: size * 1.5,
filter: streaming ? "none" : "grayscale(100%)",
opacity: streaming ? 1 : 0.6,
}}
onClick={handleEmojiClick}
title={agentStatus.url ? "Click to open URL" : undefined}
>
{agentStatus.emoji}
</div>
Expand Down
51 changes: 51 additions & 0 deletions src/services/tools/status_set.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,4 +191,55 @@ describe("status_set tool validation", () => {
expect(result.success).toBe(true);
});
});

describe("url parameter", () => {
it("should accept valid URLs", async () => {
const tool = createStatusSetTool(mockConfig);

const validUrls = [
"https://github.com/owner/repo/pull/123",
"http://example.com",
"https://example.com/path/to/resource?query=param",
];

for (const url of validUrls) {
const result = (await tool.execute!(
{ emoji: "🔍", message: "Test", url },
mockToolCallOptions
)) as {
success: boolean;
url: string;
};
expect(result.success).toBe(true);
expect(result.url).toBe(url);
}
});

it("should work without URL parameter", async () => {
const tool = createStatusSetTool(mockConfig);

const result = (await tool.execute!(
{ emoji: "✅", message: "Test" },
mockToolCallOptions
)) as {
success: boolean;
url?: string;
};
expect(result.success).toBe(true);
expect(result.url).toBeUndefined();
});

it("should omit URL from result when undefined", async () => {
const tool = createStatusSetTool(mockConfig);

const result = (await tool.execute!(
{ emoji: "✅", message: "Test", url: undefined },
mockToolCallOptions
)) as {
success: boolean;
};
expect(result.success).toBe(true);
expect("url" in result).toBe(false);
});
});
});
18 changes: 3 additions & 15 deletions src/services/tools/status_set.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,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
*/
export type StatusSetToolResult =
| {
success: true;
emoji: string;
message: string;
}
| {
success: false;
error: string;
};
import type { StatusSetToolResult } from "@/types/tools";

/**
* Validates that a string is a single emoji character
Expand Down Expand Up @@ -65,7 +52,7 @@ export const createStatusSetTool: ToolFactory = () => {
return tool({
description: TOOL_DEFINITIONS.status_set.description,
inputSchema: TOOL_DEFINITIONS.status_set.schema,
execute: ({ emoji, message }): Promise<StatusSetToolResult> => {
execute: ({ emoji, message, url }): Promise<StatusSetToolResult> => {
// Validate emoji
if (!isValidEmoji(emoji)) {
return Promise.resolve({
Expand All @@ -83,6 +70,7 @@ export const createStatusSetTool: ToolFactory = () => {
success: true,
emoji,
message: truncatedMessage,
...(url && { url }),
});
},
});
Expand Down
4 changes: 2 additions & 2 deletions src/stores/WorkspaceStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export interface WorkspaceState {
currentModel: string | null;
recencyTimestamp: number | null;
todos: TodoItem[];
agentStatus: { emoji: string; message: string } | undefined;
agentStatus: { emoji: string; message: string; url?: string } | undefined;
pendingStreamStartTime: number | null;
}

Expand All @@ -41,7 +41,7 @@ export interface WorkspaceSidebarState {
canInterrupt: boolean;
currentModel: string | null;
recencyTimestamp: number | null;
agentStatus: { emoji: string; message: string } | undefined;
agentStatus: { emoji: string; message: string; url?: string } | undefined;
}

/**
Expand Down
2 changes: 2 additions & 0 deletions src/types/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,13 +183,15 @@ export interface TodoWriteToolResult {
export interface StatusSetToolArgs {
emoji: string;
message: string;
url?: string;
}

export type StatusSetToolResult =
| {
success: true;
emoji: string;
message: string;
url?: string;
}
| {
success: false;
Expand Down
220 changes: 220 additions & 0 deletions src/utils/messages/StreamingMessageAggregator.status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -539,4 +539,224 @@ describe("StreamingMessageAggregator - Agent Status", () => {
expect(status).toEqual({ emoji: "✅", message: truncatedMessage });
expect(status?.message.length).toBe(60);
});

it("should store URL when provided in status_set", () => {
const aggregator = new StreamingMessageAggregator("2024-01-01T00:00:00.000Z");
const messageId = "msg1";
const toolCallId = "tool1";

// Start a stream
aggregator.handleStreamStart({
type: "stream-start",
workspaceId: "workspace1",
messageId,
model: "test-model",
historySequence: 1,
});

// Add a status_set tool call with URL
const testUrl = "https://github.com/owner/repo/pull/123";
aggregator.handleToolCallStart({
type: "tool-call-start",
workspaceId: "workspace1",
messageId,
toolCallId,
toolName: "status_set",
args: { emoji: "🔗", message: "PR submitted", url: testUrl },
tokens: 10,
timestamp: Date.now(),
});

// Complete the tool call
aggregator.handleToolCallEnd({
type: "tool-call-end",
workspaceId: "workspace1",
messageId,
toolCallId,
toolName: "status_set",
result: { success: true, emoji: "🔗", message: "PR submitted", url: testUrl },
});

const status = aggregator.getAgentStatus();
expect(status).toBeDefined();
expect(status?.emoji).toBe("🔗");
expect(status?.message).toBe("PR submitted");
expect(status?.url).toBe(testUrl);
});

it("should persist URL across status updates until explicitly replaced", () => {
const aggregator = new StreamingMessageAggregator("2024-01-01T00:00:00.000Z");
const messageId = "msg1";

// Start a stream
aggregator.handleStreamStart({
type: "stream-start",
workspaceId: "workspace1",
messageId,
model: "test-model",
historySequence: 1,
});

// First status with URL
const testUrl = "https://github.com/owner/repo/pull/123";
aggregator.handleToolCallStart({
type: "tool-call-start",
workspaceId: "workspace1",
messageId,
toolCallId: "tool1",
toolName: "status_set",
args: { emoji: "🔗", message: "PR submitted", url: testUrl },
tokens: 10,
timestamp: Date.now(),
});

aggregator.handleToolCallEnd({
type: "tool-call-end",
workspaceId: "workspace1",
messageId,
toolCallId: "tool1",
toolName: "status_set",
result: { success: true, emoji: "🔗", message: "PR submitted", url: testUrl },
});

expect(aggregator.getAgentStatus()?.url).toBe(testUrl);

// Second status without URL - should keep previous URL
aggregator.handleToolCallStart({
type: "tool-call-start",
workspaceId: "workspace1",
messageId,
toolCallId: "tool2",
toolName: "status_set",
args: { emoji: "✅", message: "Done" },
tokens: 10,
timestamp: Date.now(),
});

aggregator.handleToolCallEnd({
type: "tool-call-end",
workspaceId: "workspace1",
messageId,
toolCallId: "tool2",
toolName: "status_set",
result: { success: true, emoji: "✅", message: "Done" },
});

const statusAfterUpdate = aggregator.getAgentStatus();
expect(statusAfterUpdate?.emoji).toBe("✅");
expect(statusAfterUpdate?.message).toBe("Done");
expect(statusAfterUpdate?.url).toBe(testUrl); // URL persists

// Third status with different URL - should replace
const newUrl = "https://github.com/owner/repo/pull/456";
aggregator.handleToolCallStart({
type: "tool-call-start",
workspaceId: "workspace1",
messageId,
toolCallId: "tool3",
toolName: "status_set",
args: { emoji: "🔄", message: "New PR", url: newUrl },
tokens: 10,
timestamp: Date.now(),
});

aggregator.handleToolCallEnd({
type: "tool-call-end",
workspaceId: "workspace1",
messageId,
toolCallId: "tool3",
toolName: "status_set",
result: { success: true, emoji: "🔄", message: "New PR", url: newUrl },
});

const finalStatus = aggregator.getAgentStatus();
expect(finalStatus?.emoji).toBe("🔄");
expect(finalStatus?.message).toBe("New PR");
expect(finalStatus?.url).toBe(newUrl); // URL replaced
});

it("should persist URL even after status is cleared by new stream start", () => {
const aggregator = new StreamingMessageAggregator("2024-01-01T00:00:00.000Z");
const messageId1 = "msg1";

// Start first stream
aggregator.handleStreamStart({
type: "stream-start",
workspaceId: "workspace1",
messageId: messageId1,
model: "test-model",
historySequence: 1,
});

// Set status with URL in first stream
const testUrl = "https://github.com/owner/repo/pull/123";
aggregator.handleToolCallStart({
type: "tool-call-start",
workspaceId: "workspace1",
messageId: messageId1,
toolCallId: "tool1",
toolName: "status_set",
args: { emoji: "🔗", message: "PR submitted", url: testUrl },
tokens: 10,
timestamp: Date.now(),
});

aggregator.handleToolCallEnd({
type: "tool-call-end",
workspaceId: "workspace1",
messageId: messageId1,
toolCallId: "tool1",
toolName: "status_set",
result: { success: true, emoji: "🔗", message: "PR submitted", url: testUrl },
});

expect(aggregator.getAgentStatus()?.url).toBe(testUrl);

// User sends a new message, which clears the status
const userMessage = {
id: "user1",
role: "user" as const,
parts: [{ type: "text" as const, text: "Continue" }],
metadata: { timestamp: Date.now(), historySequence: 2 },
};
aggregator.handleMessage(userMessage);

expect(aggregator.getAgentStatus()).toBeUndefined(); // Status cleared

// Start second stream
const messageId2 = "msg2";
aggregator.handleStreamStart({
type: "stream-start",
workspaceId: "workspace1",
messageId: messageId2,
model: "test-model",
historySequence: 2,
});

// Set new status WITHOUT URL - should use the last URL ever seen
aggregator.handleToolCallStart({
type: "tool-call-start",
workspaceId: "workspace1",
messageId: messageId2,
toolCallId: "tool2",
toolName: "status_set",
args: { emoji: "✅", message: "Tests passed" },
tokens: 10,
timestamp: Date.now(),
});

aggregator.handleToolCallEnd({
type: "tool-call-end",
workspaceId: "workspace1",
messageId: messageId2,
toolCallId: "tool2",
toolName: "status_set",
result: { success: true, emoji: "✅", message: "Tests passed" },
});

const finalStatus = aggregator.getAgentStatus();
expect(finalStatus?.emoji).toBe("✅");
expect(finalStatus?.message).toBe("Tests passed");
expect(finalStatus?.url).toBe(testUrl); // URL from previous stream persists!
});
});
Loading