Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
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
122 changes: 121 additions & 1 deletion 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 @@ -2696,7 +2713,7 @@ describe("adeRpcServer", () => {
it("invokes ADE actions dynamically and returns status hints", async () => {
const fixture = createRuntime();
const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" });
await initialize(handler, { callerId: "agent-1", role: "agent" });
await initialize(handler, { callerId: "ade-cli:123", role: "agent" });

const response = await callTool(handler, "run_ade_action", {
domain: "git",
Expand Down Expand Up @@ -2743,6 +2760,38 @@ 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 allScheduledWork = await callTool(handler, "run_ade_action", {
domain: "chat",
action: "listScheduledWork",
});
expect(allScheduledWork?.isError).toBeUndefined();
expect(fixture.runtime.agentChatService.listScheduledWork).toHaveBeenCalledWith({});

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",
});

const aiStatus = await callTool(handler, "run_ade_action", {
domain: "ai",
action: "getStatus",
Expand Down Expand Up @@ -3079,6 +3128,61 @@ describe("adeRpcServer", () => {
text: "own-chat write",
});

const deniedScheduledWorkList = await callTool(handler, "run_ade_action", {
domain: "chat",
action: "listScheduledWork",
args: { sessionId: "chat-2", includeTerminal: true },
});
expect(deniedScheduledWorkList.isError).toBe(true);
expect(fixture.runtime.agentChatService.listScheduledWork).not.toHaveBeenCalled();

const ownScheduledWorkList = await callTool(handler, "run_ade_action", {
domain: "chat",
action: "listScheduledWork",
args: { includeTerminal: true },
});
expect(ownScheduledWorkList?.isError).toBeUndefined();
expect(fixture.runtime.agentChatService.listScheduledWork).toHaveBeenCalledWith({
sessionId: "chat-1",
includeTerminal: true,
});

const ctoHandler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" });
await initialize(ctoHandler, {
callerId: "cto-1",
role: "cto",
chatSessionId: "chat-1",
});
const ctoScheduledWorkList = await callTool(ctoHandler, "run_ade_action", {
domain: "chat",
action: "listScheduledWork",
args: { sessionId: "chat-2", includeTerminal: true },
});
expect(ctoScheduledWorkList?.isError).toBeUndefined();
expect(fixture.runtime.agentChatService.listScheduledWork).toHaveBeenLastCalledWith({
sessionId: "chat-2",
includeTerminal: true,
});

const deniedScheduledWorkCancel = await callTool(handler, "run_ade_action", {
domain: "chat",
action: "cancelScheduledWork",
args: { sessionId: "chat-2", scheduleId: "wake-2" },
});
expect(deniedScheduledWorkCancel.isError).toBe(true);
expect(fixture.runtime.agentChatService.cancelScheduledWork).not.toHaveBeenCalled();

const ownScheduledWorkCancel = await callTool(handler, "run_ade_action", {
domain: "chat",
action: "cancelScheduledWork",
args: { sessionId: "chat-1", scheduleId: "wake-1" },
});
expect(ownScheduledWorkCancel?.isError).toBeUndefined();
expect(fixture.runtime.agentChatService.cancelScheduledWork).toHaveBeenCalledWith({
sessionId: "chat-1",
scheduleId: "wake-1",
});

const peerMessage = await callTool(handler, "run_ade_action", {
domain: "chat",
action: "messageSession",
Expand Down Expand Up @@ -3646,6 +3750,22 @@ describe("adeRpcServer", () => {
sessionId: "chat-2",
text: "external write",
});

const scheduledWorkList = await callTool(handler, "run_ade_action", {
domain: "chat",
action: "listScheduledWork",
args: { sessionId: "chat-2", includeTerminal: true },
});
expect(scheduledWorkList.isError).toBe(true);
expect(fixture.runtime.agentChatService.listScheduledWork).not.toHaveBeenCalled();

const scheduledWorkCancel = await callTool(handler, "run_ade_action", {
domain: "chat",
action: "cancelScheduledWork",
args: { sessionId: "chat-2", scheduleId: "wake-2" },
});
expect(scheduledWorkCancel.isError).toBe(true);
expect(fixture.runtime.agentChatService.cancelScheduledWork).not.toHaveBeenCalled();
});

it("invokes review.startRun through ADE actions without dropping unlimited budgets", async () => {
Expand Down
24 changes: 21 additions & 3 deletions apps/ade-cli/src/adeRpcServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2387,11 +2387,20 @@ function scopeChatAdeActionArgs(
chatArgs: Record<string, unknown>,
): Record<string, unknown> {
const method = `run_ade_action:chat.${action}`;
if (action !== "readTranscript" && action !== "sendMessage") return chatArgs;
if (
action !== "readTranscript"
&& action !== "sendMessage"
&& action !== "listScheduledWork"
&& action !== "cancelScheduledWork"
Comment thread
arul28 marked this conversation as resolved.
) return chatArgs;
if (isUnboundAdeCliCaller(session)) return chatArgs;

const scopedArgs = { ...chatArgs };
const callerChatSessionId = asOptionalTrimmedString(session.identity.chatSessionId);
if (session.identity.role === "external" && !callerChatSessionId) return scopedArgs;
if (session.identity.role === "external" && !callerChatSessionId) {
if (action === "readTranscript" || action === "sendMessage") return scopedArgs;
chatAccessDenied(method);
}

const requestedSessionId = asOptionalTrimmedString(scopedArgs.sessionId);
if (!callerChatSessionId || (requestedSessionId && requestedSessionId !== callerChatSessionId)) {
Expand Down Expand Up @@ -3492,7 +3501,16 @@ async function runTool(args: {
action,
requireObjectArgsForScopedAdeAction(domain, action, argsList, hasScalarArg, rawObjectArgs),
);
} else if (!callerIsCto && domain === "chat" && (action === "readTranscript" || action === "sendMessage")) {
} else if (
!callerIsCto
&& domain === "chat"
&& (
action === "readTranscript"
|| action === "sendMessage"
|| action === "listScheduledWork"
|| action === "cancelScheduledWork"
)
) {
scopedObjectArgs = scopeChatAdeActionArgs(
session,
action,
Expand Down
56 changes: 56 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,62 @@ 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 },
},
});

for (const alias of ["schedule", "schedules"]) {
expect(expectExecutePlan(buildCliPlan(["chat", alias, "list", "chat-1"])).steps[0]?.params)
.toMatchObject({
arguments: {
action: "listScheduledWork",
args: { sessionId: "chat-1" },
},
});
expect(expectExecutePlan(buildCliPlan(["chat", alias, "cancel", "chat-1", "cron-1"])).steps[0]?.params)
.toMatchObject({
arguments: {
action: "cancelScheduledWork",
args: { sessionId: "chat-1", scheduleId: "cron-1" },
},
});
}

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
46 changes: 46 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,14 @@ function buildChatPlan(args: string[]): CliPlan {
sub === "linear-issues" ||
sub === "list-linear-issues" ||
sub === "issues";
const scheduledWorkOperation = (
sub === "scheduled-work"
|| sub === "schedules"
|| sub === "schedule"
)
&& (args[0] === "list" || args[0] === "cancel")
? firstStandalonePositional(args)
: null;
const sessionId =
readValue(args, ["--session", "--session-id"]) ??
(sub !== "create" && sub !== "list" && !linearSessionSub
Expand Down Expand Up @@ -7086,6 +7096,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
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,14 @@ describe("PersonalChatScope", () => {
interrupt: vi.fn(async () => undefined),
respondToInput: vi.fn(async () => undefined),
approveToolUse: vi.fn(async () => undefined),
cancelScheduledWork: vi.fn(async ({ sessionId, scheduleId }: {
sessionId: string;
scheduleId: string;
}) => ({
schedule: { id: scheduleId, sessionId, status: "cancelled" },
providerCancellationRequested: true,
providerCancellationConfirmed: true,
})),
updateSession: vi.fn(async () => summary),
archiveSession: vi.fn(async () => undefined),
unarchiveSession: vi.fn(async () => undefined),
Expand Down Expand Up @@ -145,11 +153,44 @@ describe("PersonalChatScope", () => {
expect(service.sendMessage).not.toHaveBeenCalled();
});

it("cancels scheduled work only for an owned personal session", async () => {
const { createRuntime, service } = fixture();
const scope = new PersonalChatScope({ createRuntime });

await expect(scope.call("cancelScheduledWork", {
sessionId: "chat-1",
scheduleId: "cron-1",
})).resolves.toMatchObject({
action: "cancelScheduledWork",
result: {
schedule: { id: "cron-1", sessionId: "chat-1", status: "cancelled" },
providerCancellationConfirmed: true,
},
});
expect(service.cancelScheduledWork).toHaveBeenCalledWith({
sessionId: "chat-1",
scheduleId: "cron-1",
});

await expect(scope.call("cancelScheduledWork", {
sessionId: "chat-1",
scheduleId: " ",
})).rejects.toThrow("scheduleId is required");
expect(service.cancelScheduledWork).toHaveBeenCalledTimes(1);
});

it("exposes only the hard allowlisted personal-chat actions", () => {
const scope = new PersonalChatScope();
const capabilities = scope.capabilities();
expect(capabilities.version).toBe(1);
expect(capabilities.actions).toEqual(expect.arrayContaining(["list", "create", "send", "updateSession", "delete"]));
expect(capabilities.actions).toEqual(expect.arrayContaining([
"list",
"create",
"send",
"cancelScheduledWork",
"updateSession",
"delete",
]));
expect(capabilities.actions).not.toContain("projects.list");
});

Expand Down
Loading