Skip to content

Commit ae89f17

Browse files
committed
feat: add URL parameter to status_set tool
- Add optional url parameter to status_set tool definition - Update backend to handle and return URL in results - Store URL in StreamingMessageAggregator with agent status - Make emoji clickable when URL present, opens in new tab - Display URL in status tooltip below message - Add comprehensive tests for URL parameter handling - URL persists until replaced by new status (no-op if same URL) Generated with `cmux`
1 parent 48fb55a commit ae89f17

File tree

8 files changed

+212
-11
lines changed

8 files changed

+212
-11
lines changed

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 & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export type StatusSetToolResult =
1111
success: true;
1212
emoji: string;
1313
message: string;
14+
url?: string;
1415
}
1516
| {
1617
success: false;
@@ -65,7 +66,7 @@ export const createStatusSetTool: ToolFactory = () => {
6566
return tool({
6667
description: TOOL_DEFINITIONS.status_set.description,
6768
inputSchema: TOOL_DEFINITIONS.status_set.schema,
68-
execute: ({ emoji, message }): Promise<StatusSetToolResult> => {
69+
execute: ({ emoji, message, url }): Promise<StatusSetToolResult> => {
6970
// Validate emoji
7071
if (!isValidEmoji(emoji)) {
7172
return Promise.resolve({
@@ -83,6 +84,7 @@ export const createStatusSetTool: ToolFactory = () => {
8384
success: true,
8485
emoji,
8586
message: truncatedMessage,
87+
...(url && { url }),
8688
});
8789
},
8890
});

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/utils/messages/StreamingMessageAggregator.status.test.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -539,4 +539,112 @@ 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 until replaced with new status", () => {
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 clear 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 finalStatus = aggregator.getAgentStatus();
646+
expect(finalStatus?.emoji).toBe("✅");
647+
expect(finalStatus?.message).toBe("Done");
648+
expect(finalStatus?.url).toBeUndefined();
649+
});
542650
});

src/utils/messages/StreamingMessageAggregator.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ export class StreamingMessageAggregator {
7171

7272
// Current agent status (updated when status_set is called)
7373
// Unlike todos, this persists after stream completion to show last activity
74-
private agentStatus: { emoji: string; message: string } | undefined = undefined;
74+
private agentStatus: { emoji: string; message: string; url?: string } | undefined = undefined;
7575

7676
// Workspace init hook state (ephemeral, not persisted to history)
7777
private initState: {
@@ -143,7 +143,7 @@ export class StreamingMessageAggregator {
143143
* Updated whenever status_set is called.
144144
* Persists after stream completion (unlike todos).
145145
*/
146-
getAgentStatus(): { emoji: string; message: string } | undefined {
146+
getAgentStatus(): { emoji: string; message: string; url?: string } | undefined {
147147
return this.agentStatus;
148148
}
149149

@@ -522,8 +522,12 @@ export class StreamingMessageAggregator {
522522
// Update agent status if this was a successful status_set
523523
// Use output instead of input to get the truncated message
524524
if (toolName === "status_set" && hasSuccessResult(output)) {
525-
const result = output as { success: true; emoji: string; message: string };
526-
this.agentStatus = { emoji: result.emoji, message: result.message };
525+
const result = output as { success: true; emoji: string; message: string; url?: string };
526+
this.agentStatus = {
527+
emoji: result.emoji,
528+
message: result.message,
529+
...(result.url && { url: result.url }),
530+
};
527531
}
528532
}
529533

src/utils/tools/toolDefinitions.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,12 @@ export const TOOL_DEFINITIONS = {
192192
"- DO NOT set status during initial exploration, file reading, or planning phases\n" +
193193
"\n" +
194194
"The status is cleared when a new user message comes in. Validate your approach is feasible \n" +
195-
"before setting status - failed tool calls after setting status indicate premature commitment.",
195+
"before setting status - failed tool calls after setting status indicate premature commitment.\n" +
196+
"\n" +
197+
"URL PARAMETER:\n" +
198+
"- Optional 'url' parameter links to external resources (e.g., PR URL: 'https://github.com/owner/repo/pull/123')\n" +
199+
"- Prefer stable URLs that don't change often - saving the same URL twice is a no-op\n" +
200+
"- URL persists until replaced by a new status",
196201
schema: z
197202
.object({
198203
emoji: z.string().describe("A single emoji character representing the current activity"),
@@ -201,6 +206,13 @@ export const TOOL_DEFINITIONS = {
201206
.describe(
202207
`A brief description of the current activity (auto-truncated to ${STATUS_MESSAGE_MAX_LENGTH} chars with ellipsis if needed)`
203208
),
209+
url: z
210+
.string()
211+
.url()
212+
.optional()
213+
.describe(
214+
"Optional URL to external resource with more details (e.g., Pull Request URL). The URL persists and is displayed to the user for easy access."
215+
),
204216
})
205217
.strict(),
206218
},

src/utils/ui/statusTooltip.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,23 @@ import { formatRelativeTime } from "@/utils/ui/dateTime";
99
export function getStatusTooltip(options: {
1010
isStreaming: boolean;
1111
streamingModel: string | null;
12-
agentStatus?: { emoji: string; message: string };
12+
agentStatus?: { emoji: string; message: string; url?: string };
1313
isUnread?: boolean;
1414
recencyTimestamp?: number | null;
1515
}): React.ReactNode {
1616
const { isStreaming, streamingModel, agentStatus, isUnread, recencyTimestamp } = options;
1717

18-
// If agent status is set, always show that message
18+
// If agent status is set, show message and URL (if available)
1919
if (agentStatus) {
20+
if (agentStatus.url) {
21+
return (
22+
<>
23+
{agentStatus.message}
24+
<br />
25+
<span style={{ opacity: 0.7, fontSize: "0.9em" }}>{agentStatus.url}</span>
26+
</>
27+
);
28+
}
2029
return agentStatus.message;
2130
}
2231

0 commit comments

Comments
 (0)