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
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
Archive,
ArrowDownToLine,
ArrowUp,
CalendarClock,
CheckCircle2,
Circle,
Code2,
Expand Down Expand Up @@ -59,6 +60,7 @@ import {
ChatComposerQueueTitle,
CommandToolCard,
ContextCompressionToolCard,
CronToolCard,
DefaultToolCard,
DirectoryListToolCard,
FileDiffToolCard,
Expand Down Expand Up @@ -678,7 +680,7 @@ function SessionPreview({

const PREVIEW_IMAGE = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='320' height='320' viewBox='0 0 320 320'%3E%3Crect width='320' height='320' rx='32' fill='%23232a35'/%3E%3Cpath d='M60 235l64-76 42 45 31-36 63 67z' fill='%237e8ca3'/%3E%3Ccircle cx='224' cy='91' r='25' fill='%23c6cfdb'/%3E%3C/svg%3E";

type StandardAmbientPreviewKind = "default" | "run-code" | "todo" | "view-image" | "web-fetch";
type StandardAmbientPreviewKind = "cron" | "default" | "run-code" | "todo" | "view-image" | "web-fetch";

function StandardAmbientPreview({
interactive,
Expand Down Expand Up @@ -753,6 +755,25 @@ function StandardAmbientPreview({
statusText={state === "loading" ? t("components.preview.flowChat.running") : "Viewed image"}
/>
);
} else if (kind === "cron") {
card = (
<CronToolCard
{...common}
action="Scheduled job:"
error={state === "error" ? t("components.preview.flowChat.failed") : undefined}
fields={[
{ label: "Schedule:", value: "Every 30 minutes" },
{ label: "Next run:", value: "Today 14:30" },
{ label: "Enabled:", value: "Yes" },
{ label: "Job id:", value: "job_7f3a91" },
]}
message={isExpanded ? "Summarize the open pull requests and report blockers." : undefined}
messageLabel="Payload"
summary={state === "error"
? t("components.preview.flowChat.failed")
: "Created Nightly repository review"}
/>
);
} else {
card = (
<TodoToolCard
Expand Down Expand Up @@ -1050,6 +1071,14 @@ export const flowChatPreviewDefinitions = {
section: "tool-card",
specimens: [{ tool: "ContextCompression" }],
},
CronToolCard: {
attention: "ambient",
codeSample: concreteCodeSample("CronToolCard"),
icon: CalendarClock,
render: (options) => <StandardAmbientPreview {...options} kind="cron" />,
section: "tool-card",
specimens: [{ tool: "Cron" }],
},
DefaultToolCard: {
attention: "ambient",
codeSample: concreteCodeSample("DefaultToolCard"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ test("FlowChat gallery renders only the real migrated tool-card components", asy
"WriteStdin",
"ExecControl",
"ContextCompression",
"Cron",
"ControlHub",
"FinalizeMiniApp",
"PublishMiniApp",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { OverflowText } from '../../primitives/OverflowText';
import type { HTMLAttributes, ReactNode } from "react";
import { Layers, MessageSquare } from "lucide-react";
import { CalendarClock, Layers, MessageSquare } from "lucide-react";
import {
AmbientToolCard,
AmbientToolCardHeader,
Expand Down Expand Up @@ -124,3 +124,10 @@ export interface SessionMessageToolCardProps
export function SessionMessageToolCard(props: SessionMessageToolCardProps) {
return <SessionToolCardBase {...props} icon={<MessageSquare aria-hidden="true" />} toolCard="session-message" />;
}

export interface CronToolCardProps
extends Omit<SessionToolCardBaseProps, "icon" | "sessions" | "toolCard"> {}

export function CronToolCard(props: CronToolCardProps) {
return <SessionToolCardBase {...props} icon={<CalendarClock aria-hidden="true" />} toolCard="cron" />;
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,16 @@ export const agentWaitToolCardMeta = {
tokens: ambientTokens,
} as const satisfies ComponentMeta;

export const cronToolCardMeta = {
category: "flow-chat",
description: "An ambient scheduled-job card with schedule, next-run, and payload details.",
maturity: "stable",
name: "CronToolCard",
props: expandableProps,
states: ["default", "hover", "loading", "expanded", "error"],
tokens: ambientTokens,
} as const satisfies ComponentMeta;

export const defaultToolCardMeta = {
category: "flow-chat",
description: "The fallback ambient card for a tool summary, sanitized input, result, and confirmation state.",
Expand Down
2 changes: 2 additions & 0 deletions design-system/packages/ui/src/flow-chat/tool-cards/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,10 @@ export {
type SearchToolCardResult,
} from "./SearchResultsToolCards";
export {
CronToolCard,
SessionControlToolCard,
SessionMessageToolCard,
type CronToolCardProps,
type SessionControlToolCardProps,
type SessionMessageToolCardProps,
type SessionToolCardField,
Expand Down
2 changes: 2 additions & 0 deletions design-system/packages/ui/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ import {
import { readFileToolCardMeta } from "./flow-chat/tool-cards/ReadFileToolCard.meta";
import {
agentWaitToolCardMeta,
cronToolCardMeta,
defaultToolCardMeta,
directoryListToolCardMeta,
getToolSpecToolCardMeta,
Expand Down Expand Up @@ -124,6 +125,7 @@ export const componentRegistry = [
comboboxMeta,
confirmDialogMeta,
contextCompressionToolCardMeta,
cronToolCardMeta,
defaultToolCardMeta,
disclosureMeta,
emptyMeta,
Expand Down
6 changes: 6 additions & 0 deletions design-system/packages/ui/tests/flow-chat-tool-card.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
AmbientToolCardHeader,
CommandToolCard,
ContextCompressionToolCard,
CronToolCard,
DefaultToolCard,
DirectoryListToolCard,
FileDiffToolCard,
Expand Down Expand Up @@ -586,6 +587,11 @@ test("every migrated FlowChat tool view publishes a stable concrete card identit
summary: "Completed",
toolName: "custom_tool",
})],
["cron", createElement(CronToolCard, {
action: "Scheduled job:",
status: "completed",
summary: "Created scheduled job",
})],
["directory-list", createElement(DirectoryListToolCard, {
results: [{ key: "src", title: "src/" }],
status: "completed",
Expand Down
1 change: 1 addition & 0 deletions design-system/packages/ui/tests/registry.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ test("registry exposes only the formal stable components", () => {
"Combobox",
"ConfirmDialog",
"ContextCompressionToolCard",
"CronToolCard",
"DefaultToolCard",
"Disclosure",
"Empty",
Expand Down
7 changes: 7 additions & 0 deletions src/apps/desktop/src/api/remote_connect_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,7 @@ fn should_fanout_peer_ui_event(event: &str) -> bool {
| "permission://event"
| AI_MODEL_CATALOG_UPDATED_EVENT
| openbitfun_core::service::workspace::WORKSPACE_CATALOG_CHANGED_EVENT
| openbitfun_core::service::cron::CRON_JOBS_CHANGED_EVENT
)
}

Expand Down Expand Up @@ -3590,6 +3591,12 @@ mod peer_event_tests {
assert!(should_fanout_peer_ui_event("workspace-catalog-changed"));
assert!(!should_fanout_peer_ui_event("workspace-identity-changed"));
}

#[test]
fn cron_job_change_hints_are_fanned_out_to_peer_controllers() {
assert!(should_fanout_peer_ui_event("cron://jobs-changed"));
assert!(!should_fanout_peer_ui_event("cron://internal"));
}
}

static SESSION_SUBSCRIPTIONS: OnceLock<
Expand Down
143 changes: 116 additions & 27 deletions src/crates/assembly/core/src/agentic/tools/implementations/cron_tool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -396,39 +396,42 @@ impl CronTool {
}
}

fn build_list_result_for_assistant(
&self,
workspace: &str,
session_id: &str,
jobs: &[CronJob],
) -> String {
fn build_list_result_for_assistant(&self, workspace: &str, jobs: &[CronJob]) -> String {
if jobs.is_empty() {
return format!(
"No scheduled jobs found for session '{}' in workspace '{}'.",
session_id, workspace
);
return format!("No scheduled jobs found in workspace '{}'.", workspace);
}

let mut lines = vec![format!(
"Found {} scheduled job(s) for session '{}' in workspace '{}'.",
"Found {} scheduled job(s) in workspace '{}'.",
jobs.len(),
session_id,
workspace,
)];
lines.push(String::new());
lines.push("| job_id | name | enabled | schedule |".to_string());
lines.push("| --- | --- | --- | --- |".to_string());
lines.push("| job_id | name | enabled | schedule | target |".to_string());
lines.push("| --- | --- | --- | --- | --- |".to_string());
for job in jobs {
lines.push(format!(
"| {} | {} | {} | {} |",
"| {} | {} | {} | {} | {} |",
Self::escape_markdown_table_cell(&job.id),
Self::escape_markdown_table_cell(&job.name),
if job.enabled { "true" } else { "false" },
Self::escape_markdown_table_cell(&Self::schedule_summary(&job.schedule)),
Self::escape_markdown_table_cell(&Self::target_summary(job)),
));
}
lines.join("\n")
}

/// Which conversation a job delivers into. The list is workspace-scoped, so
/// the agent needs this to tell its own jobs from another session's.
fn target_summary(job: &CronJob) -> String {
match &job.target {
CronJobTarget::Session { session_id, .. } => format!("session {}", session_id),
CronJobTarget::Workspace { launch, .. } => {
format!("new session ({})", launch.agent_type)
}
}
}
}

impl Default for CronTool {
Expand Down Expand Up @@ -681,7 +684,7 @@ Defaults:

Actions:
- "get_time": Return the current local time including timezone information.
- "list": List all jobs for the effective session scope.
- "list": List every job in the workspace, including jobs that belong to other sessions. Each row reports its target, and "job_id" from here works with "update", "remove", and "run".
- "add": Create a job. Requires "job". When "job.name" is omitted, uses "Cron job".
- "update": Update a job. Requires "job_id" and "patch".
- "remove": Delete a job. Requires "job_id".
Expand Down Expand Up @@ -725,7 +728,7 @@ Patch schema for "update":
"properties": {
"session_id": {
"type": "string",
"description": "Optional target session ID. Defaults to the current session for list/add."
"description": "Optional session ID. Defaults to the current session; for add it is the session the payload is delivered to, for list it selects the workspace to report on."
},
"action": {
"type": "string",
Expand Down Expand Up @@ -1085,29 +1088,51 @@ Patch schema for "update":
.resolve_effective_workspace_for_session(&session_id, context)
.await?;
let workspace = workspace_ref.workspace_path.clone();
let mut jobs = cron_service
.list_jobs_filtered(
workspace_ref.workspace_id.as_deref(),
Some(&session_id),
Some(CronJobTargetKind::Session),
)
.await;
// Workspace scope, not session scope: the product surfaces list
// every job in the workspace, and a session-only list made the
// agent report "no scheduled jobs" while the UI showed them.
let workspace_id = workspace_ref.workspace_id.as_deref();
let mut jobs = match workspace_id {
Some(workspace_id) => {
cron_service
.list_jobs_filtered(Some(workspace_id), None, None)
.await
}
// A workspace without a stable id cannot be filtered by id,
// and the service refuses to match unmigrated records by
// path. Reporting every workspace would be worse than
// reporting only this session's jobs.
None => {
cron_service
.list_jobs_filtered(
None,
Some(&session_id),
Some(CronJobTargetKind::Session),
)
.await
}
};
jobs.sort_by(|left, right| {
left.created_at_ms
.cmp(&right.created_at_ms)
.then_with(|| left.id.cmp(&right.id))
});
let serialized_jobs = Self::serialize_jobs(&jobs)?;

let result_for_assistant =
self.build_list_result_for_assistant(&workspace, &session_id, &jobs);
let mut result_for_assistant =
self.build_list_result_for_assistant(&workspace, &jobs);
if workspace_id.is_none() {
result_for_assistant.push_str(&format!(
"\n\nScope note: this workspace has no stable id, so only jobs targeting session '{}' are listed.",
session_id
));
}

Ok(vec![ToolResult::Result {
data: json!({
"success": true,
"action": "list",
"workspace": workspace,
"session_id": session_id,
"count": jobs.len(),
"jobs": serialized_jobs,
}),
Expand Down Expand Up @@ -1533,4 +1558,68 @@ mod tests {
CronTool::add_result_summary(&job_with_schedule(every_30_minutes(), "session_1"), None);
assert!(!summary.contains("end your turn"), "got: {summary}");
}

fn workspace_target_job(id: &str) -> CronJob {
CronJob {
id: id.to_string(),
name: "nightly report".to_string(),
schedule: every_30_minutes(),
payload: CronJobPayload {
text: "summarize the day".to_string(),
},
enabled: true,
target: CronJobTarget::Workspace {
workspace: CronWorkspaceRef {
workspace_id: None,
workspace_path: "/home/wsp/projects/test".to_string(),
project_workspace_path: None,
execution_target: None,
remote_connection_id: None,
remote_ssh_host: None,
},
launch: Default::default(),
},
created_at_ms: 0,
config_updated_at_ms: 0,
updated_at_ms: 0,
state: Default::default(),
}
}

#[test]
fn list_summary_covers_every_job_in_the_workspace_with_its_target() {
// The list is workspace-scoped, so a job another session owns is still
// reported — hiding it made the agent deny jobs the UI was showing.
let tool = CronTool::new();
let mut other_session_job = job_with_schedule(every_30_minutes(), "session_other");
other_session_job.id = "cron_aaaaaaaa".to_string();
let jobs = vec![other_session_job, workspace_target_job("cron_bbbbbbbb")];

let summary = tool.build_list_result_for_assistant("/home/wsp/projects/test", &jobs);

assert!(
summary.contains("Found 2 scheduled job(s)"),
"got: {summary}"
);
assert!(
summary.contains("workspace '/home/wsp/projects/test'"),
"got: {summary}"
);
assert!(!summary.contains("for session"), "got: {summary}");
// Each row says which conversation the job delivers into.
assert!(summary.contains("session session_other"), "got: {summary}");
assert!(summary.contains("new session ("), "got: {summary}");
}

#[test]
fn list_summary_reports_an_empty_workspace() {
let tool = CronTool::new();

let summary = tool.build_list_result_for_assistant("/home/wsp/projects/test", &[]);

assert_eq!(
summary,
"No scheduled jobs found in workspace '/home/wsp/projects/test'."
);
}
}
Loading
Loading