Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
4 changes: 4 additions & 0 deletions apps/ade-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,10 @@ ade chat create --lane lane-id --provider codex --no-parent # spawned chats de
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 note "running e2e shard 2/4" # update the caller's Work sidebar status; add --session <id> to target explicitly
ade chat ask "Which account should I use?" # escalate a blocking question; add --session <id> to target explicitly
ade chat settle --outcome "opened PR #841, CI green" # mark the caller settled; add --session <id> to target explicitly
ade chat unsettle # return the caller to the active lifecycle; add --session <id> to target explicitly
ade chat schedules session-id --pause # pause this agent session'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 create --in 12m --prompt "Check CI and report" --reason "CI check" --session session-id # safest one-shot form; omit --session inside the bound agent
Expand Down
31 changes: 30 additions & 1 deletion apps/ade-cli/src/adeRpcServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,11 @@ function createRuntime() {
sessionService: {
get: vi.fn(),
updateMeta: vi.fn(),
readTranscriptTail: vi.fn(() => "")
readTranscriptTail: vi.fn(() => ""),
requestAttention: vi.fn(() => true),
setStatusNote: vi.fn(() => true),
settleSession: vi.fn(() => true),
unsettleSession: vi.fn(() => true),
},
sessionDeltaService: {
getSessionDelta: vi.fn((sessionId: string) => ({ sessionId, filesChanged: 2 })),
Expand Down Expand Up @@ -324,6 +328,9 @@ function createRuntime() {
}),
write: vi.fn(),
resize: vi.fn(),
hasLivePty: vi.fn(() => false),
markSessionAttentionRequested: vi.fn(),
setSessionRuntimeState: vi.fn(() => true),
readTranscriptTail: vi.fn(async () => ""),
list: vi.fn(() => []),
enrichSessions: vi.fn((sessions: unknown[]) => sessions),
Expand Down Expand Up @@ -3287,6 +3294,28 @@ describe("adeRpcServer", () => {
text: "own-chat write",
});

const ownAttentionRequest = await callTool(handler, "run_ade_action", {
domain: "session",
action: "requestSessionAttention",
args: { message: "Which account should I use?" },
});
expect(ownAttentionRequest?.isError).toBeUndefined();
expect(fixture.runtime.sessionService.requestAttention).toHaveBeenCalledWith(
"chat-1",
"Which account should I use?",
);

const deniedAttentionRequest = await callTool(handler, "run_ade_action", {
domain: "session",
action: "requestSessionAttention",
args: { sessionId: "chat-2", message: "Cross-session question" },
});
expect(deniedAttentionRequest.isError).toBe(true);
expect(fixture.runtime.sessionService.requestAttention).not.toHaveBeenCalledWith(
"chat-2",
"Cross-session question",
);

const ownScheduledWorkCreate = await callTool(handler, "run_ade_action", {
domain: "chat",
action: "createScheduledWork",
Expand Down
18 changes: 17 additions & 1 deletion apps/ade-cli/src/adeRpcServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2405,14 +2405,19 @@ const SCOPED_CHAT_ACTIONS = new Set([
"getScheduledWorkState",
"cancelScheduledWork",
"setScheduledWorkPaused",
"requestSessionAttention",
"setSessionStatusNote",
"settleSelfSession",
"unsettleSelfSession",
]);

function scopeChatAdeActionArgs(
session: SessionState,
action: string,
chatArgs: Record<string, unknown>,
domain: "chat" | "session" = "chat",
): Record<string, unknown> {
const method = `run_ade_action:chat.${action}`;
const method = `run_ade_action:${domain}.${action}`;
if (!SCOPED_CHAT_ACTIONS.has(action)) return chatArgs;
if (isUnboundAdeCliCaller(session)) return chatArgs;

Expand Down Expand Up @@ -3508,6 +3513,17 @@ async function runTool(args: {
action,
requireObjectArgsForScopedAdeAction(domain, action, argsList, hasScalarArg, rawObjectArgs),
);
} else if (
!callerIsCto
&& domain === "session"
&& SCOPED_CHAT_ACTIONS.has(action)
) {
scopedObjectArgs = scopeChatAdeActionArgs(
session,
action,
requireObjectArgsForScopedAdeAction(domain, action, argsList, hasScalarArg, rawObjectArgs),
"session",
);
} else if (!callerIsCto && domain === "search" && action === "query") {
scopedObjectArgs = scopeSearchAdeActionArgs(
session,
Expand Down
71 changes: 71 additions & 0 deletions apps/ade-cli/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2775,6 +2775,77 @@ describe("ADE CLI", () => {
});
});

it("builds session lifecycle chat commands without inventing a session id", () => {
const cases = [
{
command: ["ask", "Which account should I use?"],
action: "requestSessionAttention",
args: { message: "Which account should I use?" },
},
{
command: ["note", "running e2e shard 2/4"],
action: "setSessionStatusNote",
args: { note: "running e2e shard 2/4" },
},
{
command: ["settle", "--outcome", "opened PR #841, CI green"],
action: "settleSelfSession",
args: { outcome: "opened PR #841, CI green" },
},
{
command: ["unsettle"],
action: "unsettleSelfSession",
args: {},
},
];

for (const testCase of cases) {
const plan = expectExecutePlan(buildCliPlan(["chat", ...testCase.command]));
expect(plan.steps[0]?.params).toEqual({
name: "run_ade_action",
arguments: {
domain: "session",
action: testCase.action,
args: testCase.args,
},
});
}

const clearNote = expectExecutePlan(buildCliPlan(["chat", "note", ""]));
expect(clearNote.steps[0]?.params).toMatchObject({
arguments: {
domain: "session",
action: "setSessionStatusNote",
args: { note: "" },
},
});
});

it.each([
["ask", ["q"], "requestSessionAttention", { message: "q" }],
["note", ["working"], "setSessionStatusNote", { note: "working" }],
["settle", ["--outcome", "done"], "settleSelfSession", { outcome: "done" }],
["unsettle", [], "unsettleSelfSession", {}],
])(
"passes --session through for chat %s",
(subcommand, commandArgs, action, expectedArgs) => {
const plan = expectExecutePlan(buildCliPlan([
"chat",
subcommand,
...commandArgs,
"--session",
"session-x",
]));
expect(plan.steps[0]?.params).toMatchObject({
arguments: {
domain: "session",
action,
args: { ...expectedArgs, sessionId: "session-x" },
},
});
},
);

it("routes chat send through the normalized message primitive", () => {
const executePlan = expectExecutePlan(buildCliPlan([
"chat",
Expand Down
83 changes: 80 additions & 3 deletions apps/ade-cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -585,7 +585,9 @@ const TOP_LEVEL_HELP = `${ADE_BANNER}
$ ade terminal list | resume | read | write | signal
Control an attached session terminal
$ ade history list | show | commits | export Inspect ADE operation timeline and lane commits
$ ade chat list | create | send | interrupt Work with ADE agent chats
$ ade chat list | create | send | ask | note
settle | unsettle | interrupt
Work with ADE agent chats
$ ade linear attach | comment | set-state | issue | graphql
Read and write attached Linear issues
$ ade github app-auth login | status | clear Authorize the machine ADE GitHub App (device flow)
Expand Down Expand Up @@ -1640,6 +1642,11 @@ const HELP_BY_COMMAND: Record<string, string> = {
$ ade chat create --lane <lane> --provider claude --model anthropic/claude-opus-4-8 --prompt "fix the tests"
$ ade chat create --from-linear-issue ENG-431 Start a chat with an attached issue + kickoff (alias: --linear-issue-json)
$ ade chat send <session> --text "next step" Send a message; steers automatically if the turn is active
$ ade chat note "running e2e shard 2/4" Update this session's Work sidebar status line
$ ade chat ask "Which account should I use?" Escalate a blocking question to the user
$ ade chat settle --outcome "PR #841, CI green" Mark this session complete
$ ade chat unsettle Return this session to the active lifecycle
These four commands default to the caller and accept --session <id>
$ ade chat steer <session> --personal --text "focus on the tradeoffs"
$ ade chat models --personal --provider codex
$ ade chat update <session> --personal --title "Trip planning"
Expand Down Expand Up @@ -6717,9 +6724,15 @@ function buildChatPlan(args: string[]): CliPlan {
&& (args[0] === "list" || args[0] === "create" || args[0] === "cancel")
? firstStandalonePositional(args)
: null;
const selfLifecycleSub =
sub === "ask" ||
sub === "note" ||
sub === "settle" ||
sub === "unsettle";
const explicitSessionId = readValue(args, ["--session", "--session-id"]);
const sessionId =
readValue(args, ["--session", "--session-id"]) ??
(sub !== "create" && sub !== "list" && !linearSessionSub
explicitSessionId ??
(sub !== "create" && sub !== "list" && !linearSessionSub && !selfLifecycleSub
? firstStandalonePositional(args)
: null);
const withSession = (base: JsonObject = {}) =>
Expand All @@ -6728,6 +6741,70 @@ function buildChatPlan(args: string[]): CliPlan {
...(sessionId ? { sessionId } : {}),
});
const requireSession = () => requireValue(sessionId, "sessionId");
if (sub === "ask") {
const message = requireValue(
readValue(args, ["--question", "--message", "--text"]) ?? args.join(" "),
"question",
);
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved
return {
kind: "execute",
label: "chat ask",
steps: [
actionStep(
"result",
"session",
"requestSessionAttention",
withSession({ message }),
),
],
};
}
if (sub === "note") {
const note = readValue(args, ["--note", "--text"]) ?? args.join(" ");
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved
return {
kind: "execute",
label: "chat note",
steps: [
actionStep(
"result",
"session",
"setSessionStatusNote",
withSession({ note }),
),
],
};
}
if (sub === "settle") {
const outcome = readValue(args, ["--outcome"]);
return {
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved
kind: "execute",
label: "chat settle",
steps: [
actionStep(
"result",
"session",
"settleSelfSession",
withSession({
...(outcome !== null ? { outcome } : {}),
}),
),
],
};
}
if (sub === "unsettle") {
return {
kind: "execute",
label: "chat unsettle",
steps: [
actionStep(
"result",
"session",
"unsettleSelfSession",
withSession(),
),
],
};
}
if (sub === "list" || sub === "ls") {
const includeArchived = readFlag(args, ["--archived", "--include-archived"]);
const excludeArchived = readFlag(args, [
Expand Down
30 changes: 30 additions & 0 deletions apps/ade-cli/src/services/push/pushPublisherService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -823,6 +823,36 @@ describe("createPushPublisherService flush", () => {
publisher.dispose();
});

it("publishes explicit CLI attention requests through the gated question path", async () => {
const { publisher, publish } = makeHarness();
await publisher.start();

publisher.handleSessionAttentionRequested("scope-1", {
sessionId: "cli-ask-1",
kind: "cli",
title: "Fix auth race",
message: "Which account should the e2e test use?",
laneId: "auth-lane",
});
await vi.advanceTimersByTimeAsync(200);

const payload = publish.mock.calls[0][0];
expect(payload.notifications[0]).toMatchObject({
title: "Fix auth race",
body: "Which account should the e2e test use?",
deepLink: "ade://session/cli-ask-1",
sessionId: "cli-ask-1",
dedupeKey: "alert:cli-ask-1:question",
});
expect(payload.liveActivity[0].contentState.runs[0]).toMatchObject({
id: "cli-ask-1",
phase: "waiting_for_input",
detail: "Which account should the e2e test use?",
});

publisher.dispose();
});

it("drops chat-owned shells and unknown sessions from CLI run tracking", async () => {
const { publisher, publish, emit, cliSessions } = makeHarness();
cliSessions.set("shell-1", { title: "attached shell", toolType: "shell", chatSessionId: "s-1" });
Expand Down
35 changes: 35 additions & 0 deletions apps/ade-cli/src/services/push/pushPublisherService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,14 @@ export type PushCliRuntimeSignal = {
runtimeState: string;
};

export type PushSessionAttentionRequest = {
sessionId: string;
kind: "chat" | "cli";
title: string;
message: string;
laneId?: string | null;
};

export type PushPrNotification = {
kind: PrNotificationKind;
prNumber: number;
Expand Down Expand Up @@ -1233,6 +1241,33 @@ export function createPushPublisherService(deps: PushPublisherDeps) {
onCliRuntimeSignal(scopeKey, signal);
},

handleSessionAttentionRequested(
scopeKey: string,
request: PushSessionAttentionRequest,
): void {
if (disposed || !request.sessionId) return;
const run = ensureRun(request.sessionId, scopeKey, request.kind);
run.title = request.title.trim() || "ADE session";
run.lane = request.laneId
? scopes.get(scopeKey)?.resolveLaneName?.(request.laneId) ?? request.laneId
: run.lane;
run.phase = "waiting_for_input";
run.detail = request.message;
Comment on lines +1254 to +1255

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clear explicit push attention when the user responds

When a chat uses ade chat ask, this sets the publisher run to waiting_for_input, but the user-response path only clears the persisted session markers. The subsequent status: started handler changes the phase only when isTerminalPhase(run.phase) is true, so this explicit waiting phase—and its remote badge/Live Activity—remains active while the response turn runs; steering an active turn may not emit a new start at all. Add a publisher-side resolution signal when clearTurnStartMarkers handles a user message, and cancel any queued question alert at the same time.

Useful? React with 👍 / 👎.

run.lastActiveAt = now();
run.metaResolved = true;
clearAlertDedupe(`alert:${request.sessionId}:question`);
enqueueAlert({
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
sessionId: request.sessionId,
dedupeKey: `alert:${request.sessionId}:question`,
render: () => ({ title: run.title || "ADE session", body: request.message }),
deepLink: `ade://session/${request.sessionId}`,
threadId: request.sessionId,
phase: "waiting",
interruptionLevel: "time-sensitive",
});
scheduleFlush(true);
},

/** Force a flush soon (e.g. right after a device registers). */
poke(): void {
scheduleFlush(true);
Expand Down
Loading