Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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 apps/ade-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,8 @@ ade chat read session-id --limit 20 --text
ade chat message session-id --kind auto --text "status/context"
ade chat steer session-id --text "active-turn context"
ade chat schedules session-id --pause # pause this chat's durable wakeups/cron/loops (omit flag to inspect, --resume to re-arm)
ade chat scheduled-work list [session-id] --all # list durable jobs; --all includes recent terminal history
ade chat scheduled-work cancel session-id job-id # cancel one job; Claude-native jobs request CronDelete in the owning chat
ade chat wait session-id --for idle --timeout-ms 600000
ade chat recover session-id --turn turn-id --action nudge # wait | nudge | retry | resume
ade chat handoff session-id --model openai/gpt-5.6-sol --note "focus on tests" # brief handoff; add --target-lane <lane-id> to hand off into another lane
Expand Down
46 changes: 46 additions & 0 deletions apps/ade-cli/src/adeRpcServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,23 @@ function createRuntime() {
lastActivityAt: "2026-03-17T19:00:00.000Z",
createdAt: "2026-03-17T19:00:00.000Z",
})),
listScheduledWork: vi.fn(async ({ sessionId, includeTerminal }: {
sessionId?: string;
includeTerminal?: boolean;
}) => [{
id: "cron-1",
sessionId: sessionId ?? "chat-1",
kind: "cron",
status: includeTerminal ? "cancelled" : "scheduled",
}]),
cancelScheduledWork: vi.fn(async ({ sessionId, scheduleId }: {
sessionId: string;
scheduleId: string;
}) => ({
schedule: { id: scheduleId, sessionId, status: "cancelled" },
providerCancellationRequested: false,
providerCancellationConfirmed: true,
})),
getChatTranscript: vi.fn(async ({ sessionId }: { sessionId: string }) => ({
sessionId,
entries: [{ role: "assistant", text: "hello", timestamp: "2026-03-17T19:00:00.000Z" }],
Expand Down Expand Up @@ -2743,6 +2760,35 @@ describe("adeRpcServer", () => {
expect(chatSummary?.isError).toBeUndefined();
expect(fixture.runtime.agentChatService.getSessionSummary).toHaveBeenCalledWith("chat-1");

const scheduledWork = await callTool(handler, "run_ade_action", {
domain: "chat",
action: "listScheduledWork",
args: { sessionId: " chat-1 ", includeTerminal: true },
});
expect(scheduledWork?.isError).toBeUndefined();
expect(fixture.runtime.agentChatService.listScheduledWork).toHaveBeenCalledWith({
sessionId: "chat-1",
includeTerminal: true,
});
expect(scheduledWork.structuredContent.result).toEqual([
expect.objectContaining({ id: "cron-1", sessionId: "chat-1", status: "cancelled" }),
]);

const cancelledWork = await callTool(handler, "run_ade_action", {
domain: "chat",
action: "cancelScheduledWork",
args: { sessionId: " chat-1 ", scheduleId: " cron-1 " },
});
expect(cancelledWork?.isError).toBeUndefined();
expect(fixture.runtime.agentChatService.cancelScheduledWork).toHaveBeenCalledWith({
sessionId: "chat-1",
scheduleId: "cron-1",
});
expect(cancelledWork.structuredContent.result).toMatchObject({
schedule: { id: "cron-1", sessionId: "chat-1", status: "cancelled" },
providerCancellationConfirmed: true,
});

const aiStatus = await callTool(handler, "run_ade_action", {
domain: "ai",
action: "getStatus",
Expand Down
39 changes: 39 additions & 0 deletions apps/ade-cli/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2909,6 +2909,45 @@ describe("ADE CLI", () => {
expect(() => buildCliPlan(["chat", "schedules", "chat-1", "--pause", "--resume"])).toThrow(
/either --pause or --resume/,
);

const list = expectExecutePlan(buildCliPlan(["chat", "scheduled-work", "list", "chat-1", "--all"]));
expect(list.label).toBe("chat scheduled-work list");
expect(list.steps[0]?.params).toMatchObject({
arguments: {
domain: "chat",
action: "listScheduledWork",
args: { sessionId: "chat-1", includeTerminal: true },
},
});

const listAllChats = expectExecutePlan(buildCliPlan(["chat", "scheduled-work", "list", "--all"]));
expect(listAllChats.steps[0]?.params).toEqual({
name: "run_ade_action",
arguments: {
domain: "chat",
action: "listScheduledWork",
args: { includeTerminal: true },
},
});

const cancel = expectExecutePlan(buildCliPlan([
"chat",
"scheduled-work",
"cancel",
"chat-1",
"cron-1",
]));
expect(cancel.label).toBe("chat scheduled-work cancel");
expect(cancel.steps[0]?.params).toMatchObject({
arguments: {
domain: "chat",
action: "cancelScheduledWork",
args: { sessionId: "chat-1", scheduleId: "cron-1" },
},
});
expect(() => buildCliPlan(["chat", "scheduled-work", "cancel", "chat-1"])).toThrow(
/scheduleId is required/,
);
});

it("rejects prototype-sensitive generic ADE action arg paths", () => {
Expand Down
42 changes: 42 additions & 0 deletions apps/ade-cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1563,6 +1563,8 @@ const HELP_BY_COMMAND: Record<string, string> = {
$ ade chat subagents <session> --text List child agents for a chat
$ ade chat schedules <session> --pause Pause this chat's durable wakeups/cron/loops
$ ade chat schedules <session> Inspect pause state + next armed wake (--resume to re-arm)
$ ade chat scheduled-work list [session] List durable jobs (--all includes recent history)
$ ade chat scheduled-work cancel <session> <id> Cancel one job; Claude crons also request CronDelete
$ ade new chat --mode cli --lane <lane> --provider claude --reasoning-effort ultracode --prompt "fix"
Start a tracked provider CLI session
$ ade chat attach-linear-issue <session> --issue-id ENG-431
Expand Down Expand Up @@ -6442,6 +6444,10 @@ function buildChatPlan(args: string[]): CliPlan {
sub === "linear-issues" ||
sub === "list-linear-issues" ||
sub === "issues";
const scheduledWorkOperation = sub === "scheduled-work"
&& (args[0] === "list" || args[0] === "cancel")
? firstStandalonePositional(args)
: null;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
const sessionId =
readValue(args, ["--session", "--session-id"]) ??
(sub !== "create" && sub !== "list" && !linearSessionSub
Expand Down Expand Up @@ -7086,6 +7092,42 @@ function buildChatPlan(args: string[]): CliPlan {
sub === "schedule" ||
sub === "scheduled-work"
) {
if (scheduledWorkOperation === "list") {
return {
kind: "execute",
label: "chat scheduled-work list",
steps: [
actionStep(
"result",
"chat",
"listScheduledWork",
collectGenericObjectArgs(args, {
...(sessionId ? { sessionId } : {}),
...(readFlag(args, ["--all", "--include-terminal"]) ? { includeTerminal: true } : {}),
}),
),
],
};
}
if (scheduledWorkOperation === "cancel") {
const targetSession = requireValue(sessionId, "sessionId");
const scheduleId = requireValue(firstStandalonePositional(args), "scheduleId");
return {
kind: "execute",
label: "chat scheduled-work cancel",
steps: [
actionStep(
"result",
"chat",
"cancelScheduledWork",
collectGenericObjectArgs(args, {
sessionId: targetSession,
scheduleId,
}),
),
],
};
}
// Per-chat scheduled-work control: pause/resume this chat's durable
// wakeups/cron/loop schedules, or inspect (no flag) the current pause
// state + next armed wake via getSessionSummary.
Expand Down
35 changes: 35 additions & 0 deletions apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,40 @@ describe("createSyncRemoteCommandService", () => {
]);
});

it("routes scheduled-work cancellation through the non-queueable mobile command", async () => {
const cancelScheduledWork = vi.fn(async ({ sessionId, scheduleId }: {
sessionId: string;
scheduleId: string;
}) => ({
schedule: { id: scheduleId, sessionId, status: "cancelled" },
providerCancellationRequested: true,
providerCancellationConfirmed: true,
}));
const { service } = createService({ agentChatService: { cancelScheduledWork } });

expect(service.getDescriptor("chat.cancelScheduledWork")).toEqual({
action: "chat.cancelScheduledWork",
scope: "project",
policy: { viewerAllowed: true, queueable: false },
});
await expect(service.execute(makePayload("chat.cancelScheduledWork", {
sessionId: "chat-1",
scheduleId: "cron-1",
}))).resolves.toMatchObject({
schedule: { id: "cron-1", sessionId: "chat-1", status: "cancelled" },
providerCancellationConfirmed: true,
});
expect(cancelScheduledWork).toHaveBeenCalledWith({
sessionId: "chat-1",
scheduleId: "cron-1",
});

await expect(service.execute(makePayload("chat.cancelScheduledWork", {
sessionId: "chat-1",
}))).rejects.toThrow("chat.cancelScheduledWork requires scheduleId.");
expect(cancelScheduledWork).toHaveBeenCalledTimes(1);
});

it("rejects unsupported Codex recovery actions before invoking chat", async () => {
const recoverCodexTurn = vi.fn();
const { service } = createService({ agentChatService: { recoverCodexTurn } });
Expand Down Expand Up @@ -1062,6 +1096,7 @@ describe("createSyncRemoteCommandService", () => {
"work.deleteSession",
"work.getSessionDelta",
"chat.getSlashCommands",
"chat.cancelScheduledWork",
"chat.getParallelLaunchState",
"chat.setParallelLaunchState",
"chat.handoff",
Expand Down
5 changes: 5 additions & 0 deletions apps/ade-cli/src/services/sync/syncRemoteCommandService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3808,6 +3808,11 @@ function registerChatRemoteCommands({ args, register }: RemoteCommandRegistratio
});
register("chat.getSummary", { viewerAllowed: true }, async (payload) =>
requireService(args.agentChatService, "Agent chat service not available.").getSessionSummary(parseAgentChatGetSummaryArgs(payload).sessionId));
register("chat.cancelScheduledWork", { viewerAllowed: true, queueable: false }, async (payload) =>
requireService(args.agentChatService, "Agent chat service not available.").cancelScheduledWork({
Comment thread
arul28 marked this conversation as resolved.
sessionId: requireString(payload.sessionId, "chat.cancelScheduledWork requires sessionId."),
scheduleId: requireString(payload.scheduleId, "chat.cancelScheduledWork requires scheduleId."),
}));
register("chat.getChatEventHistory", { viewerAllowed: true }, async (payload): Promise<AgentChatEventHistorySnapshot> => {
const agentChatService = requireService(args.agentChatService, "Agent chat service not available.");
const sessionId = requireString(payload.sessionId, "chat.getChatEventHistory requires sessionId.");
Expand Down
7 changes: 3 additions & 4 deletions apps/ade-cli/src/tuiClient/__tests__/appPolling.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -257,10 +257,9 @@ describe("AdeCodeApp polling", () => {
([domain, action]) => domain === "analytics" && action === "capture",
);

expect(analyticsCalls().map(([, , input]) => (input as { event?: string }).event)).toEqual([
"ade_app_opened",
"ade_screen_viewed",
]);
const analyticsEvents = analyticsCalls().map(([, , input]) => (input as { event?: string }).event);
expect(analyticsEvents).toHaveLength(2);
expect(new Set(analyticsEvents)).toEqual(new Set(["ade_app_opened", "ade_screen_viewed"]));
const initialAnalyticsCount = analyticsCalls().length;

await act(async () => {
Expand Down
45 changes: 45 additions & 0 deletions apps/ade-cli/src/tuiClient/__tests__/chatInfo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,51 @@ describe("deriveChatInfoSnapshot", () => {
]);
});

it("reconciles transcript events with ADE-managed scheduled work", () => {
const snapshot = deriveChatInfoSnapshot({
events: [
env("2026-07-07T12:00:00.000Z", {
type: "scheduled_work_update",
id: "stale-cron",
kind: "cron",
status: "scheduled",
title: "Stale transcript cron",
durable: true,
}, 1),
],
activeSession: session({
provider: "claude",
scheduledWork: [{
id: "managed-cron",
sessionId: "session-1",
kind: "cron",
status: "paused",
title: "Managed cron",
prompt: "Check CI",
cron: "7,27,47 * * * *",
createdAt: "2026-07-07T12:05:00.000Z",
durable: true,
cancellable: true,
}],
}),
provider: "claude",
modelLabel: "claude-opus-4-8",
laneLabel: "lane",
snapshots: [],
tokenStats: null,
goal: null,
streaming: false,
});

expect(snapshot.scheduledWork).toHaveLength(1);
expect(snapshot.scheduledWork[0]).toEqual(expect.objectContaining({
id: "managed-cron",
status: "paused",
cancellable: true,
}));
expect(snapshot.scheduledWork.some((item) => item.id === "stale-cron")).toBe(false);
});

it("partitions background_task work into backgroundWork, keeping schedule kinds in scheduledWork", () => {
const snapshot = deriveChatInfoSnapshot({
events: [
Expand Down
5 changes: 3 additions & 2 deletions apps/ade-cli/src/tuiClient/chatInfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type {
AgentChatSessionSummary,
} from "../../../desktop/src/shared/types/chat";
import { isBackgroundShellCommand, latestPlan } from "../../../desktop/src/shared/chatSubagents";
import { deriveBackgroundItems, deriveScheduleItems } from "../../../desktop/src/shared/chatScheduledWork";
import { deriveBackgroundItems, mergeManagedScheduledWorkSnapshots } from "../../../desktop/src/shared/chatScheduledWork";
import { resolveSubagentCapability } from "../../../desktop/src/shared/subagentCapabilities";
import { deriveMissionSnapshot } from "../../../desktop/src/renderer/components/chat/chatMission";
import { deriveTodoItems } from "../../../desktop/src/renderer/components/chat/chatExecutionSummary";
Expand Down Expand Up @@ -81,7 +81,8 @@ export function deriveChatInfoSnapshot(args: {
planStreamingText: trimmedOrNull(planEventRecord?.streamingText),
todos: deriveTodoItems(args.events),
// Schedule kinds only — background command tasks render in their own block.
scheduledWork: deriveScheduleItems(args.events),
scheduledWork: mergeManagedScheduledWorkSnapshots(args.events, args.activeSession?.scheduledWork)
.filter((item) => item.kind !== "background_task"),
nextWakeAt: args.activeSession?.nextWakeAt ?? null,
backgroundWork: deriveBackgroundItems(args.events),
pr: args.pr ?? null,
Expand Down
33 changes: 33 additions & 0 deletions apps/desktop/src/main/services/adeActions/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,7 @@ export const ADE_ACTION_ALLOWLIST: Partial<Record<AdeActionDomain, readonly stri
"killDroidWorker",
"launchCli",
"launchHeadless",
"listScheduledWork",
Comment thread
arul28 marked this conversation as resolved.
"listClaudePlugins",
"listClaudeSessions",
"listSessions",
Expand All @@ -483,6 +484,7 @@ export const ADE_ACTION_ALLOWLIST: Partial<Record<AdeActionDomain, readonly stri
"readTranscript",
"setClaudeOutputStyle",
"setParallelLaunchState",
"cancelScheduledWork",
Comment thread
arul28 marked this conversation as resolved.
"setScheduledWorkPaused",
"steer",
"suggestLaneNameFromPrompt",
Expand Down Expand Up @@ -775,6 +777,16 @@ const ADE_ACTION_INPUT_CONTRACTS: Partial<Record<AdeActionDomain, Partial<Record
input: "scalar sessionId string, positional argsList [sessionId], or object { sessionId }",
example: "ade actions run chat.getSessionSummary --scalar chat-123",
},
listScheduledWork: {
description: "List ADE-managed durable wakeups, cron jobs, and loops, optionally for one chat.",
input: "object { sessionId?: string, includeTerminal?: boolean }",
example: "ade actions run chat.listScheduledWork --input-json '{\"sessionId\":\"chat-123\"}' --text",
},
cancelScheduledWork: {
description: "Cancel one ADE-managed scheduled job. Claude cron cancellation is also requested through CronDelete.",
input: "object { sessionId: string, scheduleId: string }",
example: "ade actions run chat.cancelScheduledWork --input-json '{\"sessionId\":\"chat-123\",\"scheduleId\":\"cron-abc\"}' --text",
},
readTranscript: {
description: "Read recent user/assistant messages for a chat session.",
input: "object { sessionId: string, limit?: number, since?: ISO timestamp }",
Expand Down Expand Up @@ -1357,6 +1369,27 @@ function buildChatDomainService(runtime: AdeRuntime): OpaqueService | null {
service.getSessionSummary = (args?: unknown) =>
agentChatService.getSessionSummary(readStringActionArg(args, "sessionId"));
}
if (typeof base.listScheduledWork === "function") {
service.listScheduledWork = (args?: unknown) => {
const record = readObjectActionArg(args, "chat.listScheduledWork");
const sessionId = typeof record.sessionId === "string" && record.sessionId.trim()
? record.sessionId.trim()
: undefined;
return agentChatService.listScheduledWork({
...(sessionId ? { sessionId } : {}),
...(record.includeTerminal === true ? { includeTerminal: true } : {}),
});
};
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (typeof base.cancelScheduledWork === "function") {
service.cancelScheduledWork = (args?: unknown) => {
const record = readObjectActionArg(args, "chat.cancelScheduledWork");
return agentChatService.cancelScheduledWork({
sessionId: requireNonEmptyString(record.sessionId, "sessionId"),
scheduleId: requireNonEmptyString(record.scheduleId, "scheduleId"),
});
};
}
if (typeof base.getChatEventHistory === "function") {
service.getChatEventHistory = (args?: unknown) => {
const { sessionId, options } = readChatHistoryActionArgs(args, "chat.getChatEventHistory");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,11 @@ describe("buildCodingAgentSystemPrompt", () => {
expect(result).toContain("ScheduleWakeup");
expect(result).toContain("CronCreate");
expect(result).toContain("can start a later unattended turn");
expect(result).toContain("pause all scheduled work in Settings");
expect(result).toContain("session-bound by default");
expect(result).toContain("CronCreate` always creates a new job");
expect(result).toContain("`CronList` and `CronDelete`");
expect(result).toContain("auto-expire after seven days");
expect(result).toContain("project-wide manager in Settings");
expect(result).not.toContain("unavailable in this ADE chat");
expect(result).not.toContain("will not start a later turn by itself");
});
Expand Down
Loading