Skip to content

Commit 29bdd8e

Browse files
authored
🤖 feat: add URL parameter to status_set tool (#494)
Adds optional URL parameter to `status_set` tool for linking to external resources (e.g., Pull Request URLs). When URL is provided: - Stored with agent status until replaced - Displayed in status tooltip - Emoji becomes clickable, opens URL in new tab ```typescript status_set({ emoji: "🔗", message: "PR #123 submitted", url: "https://github.com/owner/repo/pull/123" }); ``` _Generated with `cmux`_
1 parent f0290f6 commit 29bdd8e

File tree

10 files changed

+342
-26
lines changed

10 files changed

+342
-26
lines changed

eslint.config.mjs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,10 @@ export default defineConfig([
368368
},
369369
{
370370
// Frontend architectural boundary - prevent services and tokenizer imports
371+
// Note: src/utils/** and src/stores/** are not included because:
372+
// - Some utils are shared between main/renderer (e.g., utils/tools registry)
373+
// - Stores can import from utils/messages which is renderer-safe
374+
// - Type-only imports from services are safe (types live in src/types/)
371375
files: ["src/components/**", "src/contexts/**", "src/hooks/**", "src/App.tsx"],
372376
rules: {
373377
"no-restricted-imports": [

src/components/AgentStatusIndicator.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,14 +74,29 @@ export const AgentStatusIndicator: React.FC<AgentStatusIndicatorProps> = ({
7474
/>
7575
);
7676

77+
const handleEmojiClick = useCallback(
78+
(e: React.MouseEvent) => {
79+
if (agentStatus?.url) {
80+
e.stopPropagation(); // Prevent workspace selection
81+
window.open(agentStatus.url, "_blank", "noopener,noreferrer");
82+
}
83+
},
84+
[agentStatus?.url]
85+
);
86+
7787
const emoji = agentStatus ? (
7888
<div
79-
className="flex shrink-0 items-center justify-center transition-all duration-200"
89+
className={cn(
90+
"flex shrink-0 items-center justify-center transition-all duration-200",
91+
agentStatus.url && "cursor-pointer hover:opacity-80"
92+
)}
8093
style={{
8194
fontSize: size * 1.5,
8295
filter: streaming ? "none" : "grayscale(100%)",
8396
opacity: streaming ? 1 : 0.6,
8497
}}
98+
onClick={handleEmojiClick}
99+
title={agentStatus.url ? "Click to open URL" : undefined}
85100
>
86101
{agentStatus.emoji}
87102
</div>

src/services/tools/status_set.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,4 +191,55 @@ describe("status_set tool validation", () => {
191191
expect(result.success).toBe(true);
192192
});
193193
});
194+
195+
describe("url parameter", () => {
196+
it("should accept valid URLs", async () => {
197+
const tool = createStatusSetTool(mockConfig);
198+
199+
const validUrls = [
200+
"https://github.com/owner/repo/pull/123",
201+
"http://example.com",
202+
"https://example.com/path/to/resource?query=param",
203+
];
204+
205+
for (const url of validUrls) {
206+
const result = (await tool.execute!(
207+
{ emoji: "🔍", message: "Test", url },
208+
mockToolCallOptions
209+
)) as {
210+
success: boolean;
211+
url: string;
212+
};
213+
expect(result.success).toBe(true);
214+
expect(result.url).toBe(url);
215+
}
216+
});
217+
218+
it("should work without URL parameter", async () => {
219+
const tool = createStatusSetTool(mockConfig);
220+
221+
const result = (await tool.execute!(
222+
{ emoji: "✅", message: "Test" },
223+
mockToolCallOptions
224+
)) as {
225+
success: boolean;
226+
url?: string;
227+
};
228+
expect(result.success).toBe(true);
229+
expect(result.url).toBeUndefined();
230+
});
231+
232+
it("should omit URL from result when undefined", async () => {
233+
const tool = createStatusSetTool(mockConfig);
234+
235+
const result = (await tool.execute!(
236+
{ emoji: "✅", message: "Test", url: undefined },
237+
mockToolCallOptions
238+
)) as {
239+
success: boolean;
240+
};
241+
expect(result.success).toBe(true);
242+
expect("url" in result).toBe(false);
243+
});
244+
});
194245
});

src/services/tools/status_set.ts

Lines changed: 3 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,7 @@ import { tool } from "ai";
22
import type { ToolFactory } from "@/utils/tools/tools";
33
import { TOOL_DEFINITIONS } from "@/utils/tools/toolDefinitions";
44
import { STATUS_MESSAGE_MAX_LENGTH } from "@/constants/toolLimits";
5-
6-
/**
7-
* Result type for status_set tool
8-
*/
9-
export type StatusSetToolResult =
10-
| {
11-
success: true;
12-
emoji: string;
13-
message: string;
14-
}
15-
| {
16-
success: false;
17-
error: string;
18-
};
5+
import type { StatusSetToolResult } from "@/types/tools";
196

207
/**
218
* Validates that a string is a single emoji character
@@ -65,7 +52,7 @@ export const createStatusSetTool: ToolFactory = () => {
6552
return tool({
6653
description: TOOL_DEFINITIONS.status_set.description,
6754
inputSchema: TOOL_DEFINITIONS.status_set.schema,
68-
execute: ({ emoji, message }): Promise<StatusSetToolResult> => {
55+
execute: ({ emoji, message, url }): Promise<StatusSetToolResult> => {
6956
// Validate emoji
7057
if (!isValidEmoji(emoji)) {
7158
return Promise.resolve({
@@ -83,6 +70,7 @@ export const createStatusSetTool: ToolFactory = () => {
8370
success: true,
8471
emoji,
8572
message: truncatedMessage,
73+
...(url && { url }),
8674
});
8775
},
8876
});

src/stores/WorkspaceStore.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ export interface WorkspaceState {
2929
currentModel: string | null;
3030
recencyTimestamp: number | null;
3131
todos: TodoItem[];
32-
agentStatus: { emoji: string; message: string } | undefined;
32+
agentStatus: { emoji: string; message: string; url?: string } | undefined;
3333
pendingStreamStartTime: number | null;
3434
}
3535

@@ -41,7 +41,7 @@ export interface WorkspaceSidebarState {
4141
canInterrupt: boolean;
4242
currentModel: string | null;
4343
recencyTimestamp: number | null;
44-
agentStatus: { emoji: string; message: string } | undefined;
44+
agentStatus: { emoji: string; message: string; url?: string } | undefined;
4545
}
4646

4747
/**

src/types/tools.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,13 +183,15 @@ export interface TodoWriteToolResult {
183183
export interface StatusSetToolArgs {
184184
emoji: string;
185185
message: string;
186+
url?: string;
186187
}
187188

188189
export type StatusSetToolResult =
189190
| {
190191
success: true;
191192
emoji: string;
192193
message: string;
194+
url?: string;
193195
}
194196
| {
195197
success: false;

src/utils/messages/StreamingMessageAggregator.status.test.ts

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -539,4 +539,224 @@ describe("StreamingMessageAggregator - Agent Status", () => {
539539
expect(status).toEqual({ emoji: "✅", message: truncatedMessage });
540540
expect(status?.message.length).toBe(60);
541541
});
542+
543+
it("should store URL when provided in status_set", () => {
544+
const aggregator = new StreamingMessageAggregator("2024-01-01T00:00:00.000Z");
545+
const messageId = "msg1";
546+
const toolCallId = "tool1";
547+
548+
// Start a stream
549+
aggregator.handleStreamStart({
550+
type: "stream-start",
551+
workspaceId: "workspace1",
552+
messageId,
553+
model: "test-model",
554+
historySequence: 1,
555+
});
556+
557+
// Add a status_set tool call with URL
558+
const testUrl = "https://github.com/owner/repo/pull/123";
559+
aggregator.handleToolCallStart({
560+
type: "tool-call-start",
561+
workspaceId: "workspace1",
562+
messageId,
563+
toolCallId,
564+
toolName: "status_set",
565+
args: { emoji: "🔗", message: "PR submitted", url: testUrl },
566+
tokens: 10,
567+
timestamp: Date.now(),
568+
});
569+
570+
// Complete the tool call
571+
aggregator.handleToolCallEnd({
572+
type: "tool-call-end",
573+
workspaceId: "workspace1",
574+
messageId,
575+
toolCallId,
576+
toolName: "status_set",
577+
result: { success: true, emoji: "🔗", message: "PR submitted", url: testUrl },
578+
});
579+
580+
const status = aggregator.getAgentStatus();
581+
expect(status).toBeDefined();
582+
expect(status?.emoji).toBe("🔗");
583+
expect(status?.message).toBe("PR submitted");
584+
expect(status?.url).toBe(testUrl);
585+
});
586+
587+
it("should persist URL across status updates until explicitly replaced", () => {
588+
const aggregator = new StreamingMessageAggregator("2024-01-01T00:00:00.000Z");
589+
const messageId = "msg1";
590+
591+
// Start a stream
592+
aggregator.handleStreamStart({
593+
type: "stream-start",
594+
workspaceId: "workspace1",
595+
messageId,
596+
model: "test-model",
597+
historySequence: 1,
598+
});
599+
600+
// First status with URL
601+
const testUrl = "https://github.com/owner/repo/pull/123";
602+
aggregator.handleToolCallStart({
603+
type: "tool-call-start",
604+
workspaceId: "workspace1",
605+
messageId,
606+
toolCallId: "tool1",
607+
toolName: "status_set",
608+
args: { emoji: "🔗", message: "PR submitted", url: testUrl },
609+
tokens: 10,
610+
timestamp: Date.now(),
611+
});
612+
613+
aggregator.handleToolCallEnd({
614+
type: "tool-call-end",
615+
workspaceId: "workspace1",
616+
messageId,
617+
toolCallId: "tool1",
618+
toolName: "status_set",
619+
result: { success: true, emoji: "🔗", message: "PR submitted", url: testUrl },
620+
});
621+
622+
expect(aggregator.getAgentStatus()?.url).toBe(testUrl);
623+
624+
// Second status without URL - should keep previous URL
625+
aggregator.handleToolCallStart({
626+
type: "tool-call-start",
627+
workspaceId: "workspace1",
628+
messageId,
629+
toolCallId: "tool2",
630+
toolName: "status_set",
631+
args: { emoji: "✅", message: "Done" },
632+
tokens: 10,
633+
timestamp: Date.now(),
634+
});
635+
636+
aggregator.handleToolCallEnd({
637+
type: "tool-call-end",
638+
workspaceId: "workspace1",
639+
messageId,
640+
toolCallId: "tool2",
641+
toolName: "status_set",
642+
result: { success: true, emoji: "✅", message: "Done" },
643+
});
644+
645+
const statusAfterUpdate = aggregator.getAgentStatus();
646+
expect(statusAfterUpdate?.emoji).toBe("✅");
647+
expect(statusAfterUpdate?.message).toBe("Done");
648+
expect(statusAfterUpdate?.url).toBe(testUrl); // URL persists
649+
650+
// Third status with different URL - should replace
651+
const newUrl = "https://github.com/owner/repo/pull/456";
652+
aggregator.handleToolCallStart({
653+
type: "tool-call-start",
654+
workspaceId: "workspace1",
655+
messageId,
656+
toolCallId: "tool3",
657+
toolName: "status_set",
658+
args: { emoji: "🔄", message: "New PR", url: newUrl },
659+
tokens: 10,
660+
timestamp: Date.now(),
661+
});
662+
663+
aggregator.handleToolCallEnd({
664+
type: "tool-call-end",
665+
workspaceId: "workspace1",
666+
messageId,
667+
toolCallId: "tool3",
668+
toolName: "status_set",
669+
result: { success: true, emoji: "🔄", message: "New PR", url: newUrl },
670+
});
671+
672+
const finalStatus = aggregator.getAgentStatus();
673+
expect(finalStatus?.emoji).toBe("🔄");
674+
expect(finalStatus?.message).toBe("New PR");
675+
expect(finalStatus?.url).toBe(newUrl); // URL replaced
676+
});
677+
678+
it("should persist URL even after status is cleared by new stream start", () => {
679+
const aggregator = new StreamingMessageAggregator("2024-01-01T00:00:00.000Z");
680+
const messageId1 = "msg1";
681+
682+
// Start first stream
683+
aggregator.handleStreamStart({
684+
type: "stream-start",
685+
workspaceId: "workspace1",
686+
messageId: messageId1,
687+
model: "test-model",
688+
historySequence: 1,
689+
});
690+
691+
// Set status with URL in first stream
692+
const testUrl = "https://github.com/owner/repo/pull/123";
693+
aggregator.handleToolCallStart({
694+
type: "tool-call-start",
695+
workspaceId: "workspace1",
696+
messageId: messageId1,
697+
toolCallId: "tool1",
698+
toolName: "status_set",
699+
args: { emoji: "🔗", message: "PR submitted", url: testUrl },
700+
tokens: 10,
701+
timestamp: Date.now(),
702+
});
703+
704+
aggregator.handleToolCallEnd({
705+
type: "tool-call-end",
706+
workspaceId: "workspace1",
707+
messageId: messageId1,
708+
toolCallId: "tool1",
709+
toolName: "status_set",
710+
result: { success: true, emoji: "🔗", message: "PR submitted", url: testUrl },
711+
});
712+
713+
expect(aggregator.getAgentStatus()?.url).toBe(testUrl);
714+
715+
// User sends a new message, which clears the status
716+
const userMessage = {
717+
id: "user1",
718+
role: "user" as const,
719+
parts: [{ type: "text" as const, text: "Continue" }],
720+
metadata: { timestamp: Date.now(), historySequence: 2 },
721+
};
722+
aggregator.handleMessage(userMessage);
723+
724+
expect(aggregator.getAgentStatus()).toBeUndefined(); // Status cleared
725+
726+
// Start second stream
727+
const messageId2 = "msg2";
728+
aggregator.handleStreamStart({
729+
type: "stream-start",
730+
workspaceId: "workspace1",
731+
messageId: messageId2,
732+
model: "test-model",
733+
historySequence: 2,
734+
});
735+
736+
// Set new status WITHOUT URL - should use the last URL ever seen
737+
aggregator.handleToolCallStart({
738+
type: "tool-call-start",
739+
workspaceId: "workspace1",
740+
messageId: messageId2,
741+
toolCallId: "tool2",
742+
toolName: "status_set",
743+
args: { emoji: "✅", message: "Tests passed" },
744+
tokens: 10,
745+
timestamp: Date.now(),
746+
});
747+
748+
aggregator.handleToolCallEnd({
749+
type: "tool-call-end",
750+
workspaceId: "workspace1",
751+
messageId: messageId2,
752+
toolCallId: "tool2",
753+
toolName: "status_set",
754+
result: { success: true, emoji: "✅", message: "Tests passed" },
755+
});
756+
757+
const finalStatus = aggregator.getAgentStatus();
758+
expect(finalStatus?.emoji).toBe("✅");
759+
expect(finalStatus?.message).toBe("Tests passed");
760+
expect(finalStatus?.url).toBe(testUrl); // URL from previous stream persists!
761+
});
542762
});

0 commit comments

Comments
 (0)