@vellumai/assistant 0.12.2-staging.3 → 0.12.2-staging.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.12.2-staging.3",
3
+ "version": "0.12.2-staging.4",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,260 @@
1
+ import { desc, eq } from "drizzle-orm";
2
+
3
+ import { getDb } from "../persistence/db-connection.js";
4
+ import { acpSessionHistory } from "../persistence/schema/index.js";
5
+ import { getLogger } from "../util/logger.js";
6
+ import { acpAuthMarkerStillCurrent } from "./acp-auth-marker-store.js";
7
+ import { getAcpSessionManager } from "./index.js";
8
+ import type { AcpSessionManager } from "./session-manager.js";
9
+ import type { AcpSessionState } from "./types.js";
10
+
11
+ const log = getLogger("acp:session-snapshot");
12
+
13
+ export interface AcpSessionSnapshot {
14
+ id: string;
15
+ agentId: string;
16
+ acpSessionId: string;
17
+ parentConversationId: string;
18
+ status: string;
19
+ startedAt: number;
20
+ completedAt?: number | null;
21
+ error?: string | null;
22
+ stopReason?: string | null;
23
+ task?: string;
24
+ parentToolUseId?: string;
25
+ authErrorCode?: string;
26
+ authErrorCredential?: string;
27
+ model?: string;
28
+ availableModels?: AcpSessionState["availableModels"];
29
+ modelRevisionEpoch?: string;
30
+ modelRevision?: number;
31
+ usedTokens?: number;
32
+ contextSize?: number;
33
+ costAmount?: number;
34
+ costCurrency?: string;
35
+ inputTokens?: number;
36
+ outputTokens?: number;
37
+ eventLog?: unknown[];
38
+ source: "live" | "history";
39
+ resumable: boolean;
40
+ cwd?: string | null;
41
+ }
42
+
43
+ export interface AcpSessionSnapshotPage {
44
+ sessions: AcpSessionSnapshot[];
45
+ sawEveryHistoryRow: boolean;
46
+ }
47
+
48
+ interface SnapshotOptions {
49
+ includeEventLog?: boolean;
50
+ }
51
+
52
+ function fromLiveState(
53
+ state: AcpSessionState,
54
+ manager: AcpSessionManager,
55
+ opts: SnapshotOptions,
56
+ ): AcpSessionSnapshot {
57
+ return {
58
+ id: state.id,
59
+ agentId: state.agentId,
60
+ acpSessionId: state.acpSessionId,
61
+ parentConversationId: state.parentConversationId,
62
+ status: state.status,
63
+ startedAt: state.startedAt,
64
+ completedAt: state.completedAt ?? null,
65
+ error: state.error ?? null,
66
+ stopReason: state.stopReason ?? null,
67
+ task: state.task,
68
+ parentToolUseId: state.parentToolUseId,
69
+ authErrorCode: state.authErrorCode,
70
+ authErrorCredential: state.authErrorCredential,
71
+ model: state.model,
72
+ availableModels: state.availableModels,
73
+ modelRevisionEpoch: state.modelRevisionEpoch,
74
+ modelRevision: state.modelRevision,
75
+ usedTokens: state.latestUsage?.usedTokens,
76
+ contextSize: state.latestUsage?.contextSize,
77
+ costAmount: state.latestUsage?.costAmount,
78
+ costCurrency: state.latestUsage?.costCurrency,
79
+ inputTokens: state.latestUsage?.inputTokens,
80
+ outputTokens: state.latestUsage?.outputTokens,
81
+ eventLog: opts.includeEventLog
82
+ ? manager.getBufferedUpdates(state.id)
83
+ : undefined,
84
+ source: "live",
85
+ resumable: false,
86
+ };
87
+ }
88
+
89
+ function isResumableHistoryRow(
90
+ row: typeof acpSessionHistory.$inferSelect,
91
+ ): boolean {
92
+ return Boolean(row.cwd && row.acpSessionId);
93
+ }
94
+
95
+ export function snapshotHistoryRow(
96
+ row: typeof acpSessionHistory.$inferSelect,
97
+ opts: SnapshotOptions = { includeEventLog: true },
98
+ ): AcpSessionSnapshot {
99
+ let eventLog: unknown[] | undefined;
100
+ if (opts.includeEventLog !== false) {
101
+ eventLog = [];
102
+ try {
103
+ const parsed = JSON.parse(row.eventLogJson) as unknown;
104
+ if (Array.isArray(parsed)) {
105
+ eventLog = parsed;
106
+ }
107
+ } catch (err) {
108
+ log.warn(
109
+ { id: row.id, err },
110
+ "Failed to parse event_log_json for ACP session history row",
111
+ );
112
+ }
113
+ }
114
+
115
+ return {
116
+ id: row.id,
117
+ agentId: row.agentId,
118
+ acpSessionId: row.acpSessionId,
119
+ parentConversationId: row.parentConversationId,
120
+ status: row.status,
121
+ startedAt: row.startedAt,
122
+ completedAt: row.completedAt,
123
+ error: row.error,
124
+ stopReason: row.stopReason,
125
+ task: row.task ?? undefined,
126
+ parentToolUseId: row.parentToolUseId ?? undefined,
127
+ authErrorCode: row.authErrorCode ?? undefined,
128
+ authErrorCredential: row.authErrorCredential ?? undefined,
129
+ usedTokens: row.usedTokens ?? undefined,
130
+ contextSize: row.contextSize ?? undefined,
131
+ costAmount: row.costAmount ?? undefined,
132
+ costCurrency: row.costCurrency ?? undefined,
133
+ inputTokens: row.inputTokens ?? undefined,
134
+ outputTokens: row.outputTokens ?? undefined,
135
+ eventLog,
136
+ source: "history",
137
+ resumable: isResumableHistoryRow(row),
138
+ cwd: row.cwd,
139
+ };
140
+ }
141
+
142
+ /**
143
+ * Blank `authErrorCode` on any session whose marker no longer describes the
144
+ * credential its agent would resolve.
145
+ *
146
+ * This comparison is what retires a Connect card: the marker no longer
147
+ * describing the credential in use. Applied after merging rather than inside
148
+ * the query, so live sessions and history rows are judged by the same rule.
149
+ *
150
+ * Resolved per agent and memoised across the batch, because precedence is per
151
+ * agent and each resolution costs a vault read.
152
+ */
153
+ export async function withCurrentAuthMarkers<
154
+ T extends {
155
+ agentId: string;
156
+ authErrorCode?: string;
157
+ authErrorCredential?: string;
158
+ },
159
+ >(
160
+ sessions: readonly T[],
161
+ resolvedFor: (agentId: string) => Promise<string | undefined>,
162
+ ): Promise<T[]> {
163
+ if (!sessions.some((session) => session.authErrorCode !== undefined)) {
164
+ return [...sessions];
165
+ }
166
+ const resolvedByAgent = new Map<string, string | undefined>();
167
+ const resolve = async (agentId: string) => {
168
+ if (!resolvedByAgent.has(agentId)) {
169
+ resolvedByAgent.set(agentId, await resolvedFor(agentId));
170
+ }
171
+ return resolvedByAgent.get(agentId);
172
+ };
173
+ const judged: T[] = [];
174
+ for (const session of sessions) {
175
+ if (session.authErrorCode === undefined) {
176
+ judged.push(session);
177
+ continue;
178
+ }
179
+ const current = acpAuthMarkerStillCurrent(
180
+ session.authErrorCredential,
181
+ await resolve(session.agentId),
182
+ );
183
+ judged.push(
184
+ current ? session : { ...session, authErrorCode: undefined },
185
+ );
186
+ }
187
+ return judged;
188
+ }
189
+
190
+ export function getAcpSessionSnapshot(
191
+ acpSessionId: string,
192
+ opts: SnapshotOptions = {},
193
+ ): AcpSessionSnapshot | undefined {
194
+ const manager = getAcpSessionManager();
195
+ const snapshotOptions = {
196
+ includeEventLog: opts.includeEventLog ?? true,
197
+ };
198
+ const live = (manager.getStatus() as AcpSessionState[]).find(
199
+ (state) => state.id === acpSessionId,
200
+ );
201
+ if (live) {
202
+ return fromLiveState(live, manager, snapshotOptions);
203
+ }
204
+
205
+ const row = getDb()
206
+ .select()
207
+ .from(acpSessionHistory)
208
+ .where(eq(acpSessionHistory.id, acpSessionId))
209
+ .get();
210
+ return row ? snapshotHistoryRow(row, snapshotOptions) : undefined;
211
+ }
212
+
213
+ export function listAcpSessionSnapshots(opts: {
214
+ limit: number;
215
+ conversationId?: string;
216
+ includeEventLog?: boolean;
217
+ }): AcpSessionSnapshotPage {
218
+ const manager = getAcpSessionManager();
219
+ const inMemory = manager.getStatus() as AcpSessionState[];
220
+ const snapshotOptions = {
221
+ includeEventLog: opts.includeEventLog ?? true,
222
+ };
223
+
224
+ const merged = new Map<string, AcpSessionSnapshot>();
225
+ for (const state of inMemory) {
226
+ if (
227
+ opts.conversationId &&
228
+ state.parentConversationId !== opts.conversationId
229
+ ) {
230
+ continue;
231
+ }
232
+ merged.set(state.id, fromLiveState(state, manager, snapshotOptions));
233
+ }
234
+
235
+ const db = getDb();
236
+ const baseQuery = db.select().from(acpSessionHistory);
237
+ const filtered = opts.conversationId
238
+ ? baseQuery.where(
239
+ eq(acpSessionHistory.parentConversationId, opts.conversationId),
240
+ )
241
+ : baseQuery;
242
+ const historyLimit = opts.limit + merged.size;
243
+ const historyRows = filtered
244
+ .orderBy(desc(acpSessionHistory.startedAt))
245
+ .limit(historyLimit)
246
+ .all();
247
+
248
+ for (const row of historyRows) {
249
+ if (!merged.has(row.id)) {
250
+ merged.set(row.id, snapshotHistoryRow(row, snapshotOptions));
251
+ }
252
+ }
253
+
254
+ return {
255
+ sessions: Array.from(merged.values()).sort(
256
+ (a, b) => b.startedAt - a.startedAt,
257
+ ),
258
+ sawEveryHistoryRow: historyRows.length < historyLimit,
259
+ };
260
+ }
@@ -125,5 +125,5 @@ Default to the conversation's current working directory when spawning an agent.
125
125
 
126
126
  - The spawned agent runs autonomously with its own tools, file editing, and terminal access.
127
127
  - Results are streamed back and injected into the conversation when the agent completes.
128
- - Use `acp_status` to check on running agents and `acp_abort` to stop them.
128
+ - Use `acp_status` to inspect running and idle agents. Use `acp_steer` to attempt follow-up work on an idle session; do not replace it with a new `acp_spawn`.
129
129
  - The `cwd` parameter controls where the agent works - set it to the project root the user wants the agent to operate in.
@@ -33,7 +33,7 @@
33
33
  },
34
34
  {
35
35
  "name": "acp_status",
36
- "description": "Get the status of a specific ACP session or list all ACP sessions. Only use this when the user explicitly asks about ACP session status - do NOT poll automatically, as you will be notified when sessions complete.",
36
+ "description": "Get the status of a specific ACP session or list recent ACP sessions. Cleanly completed sessions with durable resume metadata are returned as idle; use `acp_steer` to attempt follow-up work on them instead of starting over with `acp_spawn`. Only use this when the user explicitly asks about ACP session status - do NOT poll automatically, as you will be notified when sessions complete.",
37
37
  "category": "orchestration",
38
38
  "risk": "low",
39
39
  "input_schema": {
@@ -41,7 +41,7 @@
41
41
  "properties": {
42
42
  "acp_session_id": {
43
43
  "type": "string",
44
- "description": "Optional ACP session ID to query. If omitted, returns all ACP sessions."
44
+ "description": "Optional ACP session ID to query. If omitted, returns recent ACP sessions, including active and resumable idle sessions."
45
45
  }
46
46
  },
47
47
  "required": []
@@ -736,3 +736,178 @@ describe("schedule.result pass-through in notification decision engine", () => {
736
736
  expect(decision.shouldNotify).toBe(true);
737
737
  });
738
738
  });
739
+
740
+ const SCHEDULER_OWNED_REPORT = [
741
+ "# Daily briefing",
742
+ "",
743
+ "## Overnight",
744
+ "- Calendar is clear until 10:00.",
745
+ "- Two pull requests are waiting on review.",
746
+ "",
747
+ "## Account security",
748
+ "- Urgent: a sign-in from a new device needs confirmation before the weekly sync.",
749
+ "",
750
+ "## This week",
751
+ "- Project kickoff on Wednesday.",
752
+ "- Weekly planning on Friday.",
753
+ "- Follow up on the draft status update.",
754
+ ].join("\n");
755
+
756
+ function makeSchedulerShareSignal(
757
+ overrides?: Partial<NotificationSignal>,
758
+ ): NotificationSignal {
759
+ return {
760
+ signalId: "sig-scheduler-share-test-1",
761
+ createdAt: Date.now(),
762
+ sourceChannel: "scheduler",
763
+ sourceContextId: "conv-xyz",
764
+ sourceEventName: "assistant.share",
765
+ contextPayload: {
766
+ requestedMessage: SCHEDULER_OWNED_REPORT,
767
+ requestedBySource: "scheduler",
768
+ requestedTitle: "Your day",
769
+ },
770
+ attentionHints: {
771
+ requiresAction: true,
772
+ urgency: "high",
773
+ isAsyncBackground: true,
774
+ visibleInSourceNow: false,
775
+ },
776
+ ...overrides,
777
+ };
778
+ }
779
+
780
+ describe("scheduler requested-message pass-through in notification decision engine", () => {
781
+ beforeEach(() => {
782
+ persistedDecisions = [];
783
+ });
784
+
785
+ test("keeps a scheduler-owned report verbatim on every urgency-selected channel", async () => {
786
+ const available = [
787
+ "vellum",
788
+ "telegram",
789
+ "platform",
790
+ ] as NotificationChannel[];
791
+ const decision = await evaluateSignal(
792
+ makeSchedulerShareSignal(),
793
+ available,
794
+ );
795
+
796
+ expect(decision.shouldNotify).toBe(true);
797
+ expect(decision.selectedChannels).toEqual(available);
798
+ expect(decision.reasoningSummary).toBe(
799
+ "scheduler requested-message pass-through",
800
+ );
801
+ expect(decision.verbatimCopy).toBe(true);
802
+ expect(decision.fallbackUsed).toBe(false);
803
+ for (const ch of available) {
804
+ expect(decision.renderedCopy[ch]?.title).toBe("Your day");
805
+ expect(decision.renderedCopy[ch]?.body).toBe(SCHEDULER_OWNED_REPORT);
806
+ expect(decision.renderedCopy[ch]?.conversationSeedMessage).toBe(
807
+ SCHEDULER_OWNED_REPORT,
808
+ );
809
+ }
810
+ });
811
+
812
+ test("copies the complete report onto the urgency-narrowed channel set", async () => {
813
+ const available = [
814
+ "vellum",
815
+ "telegram",
816
+ "platform",
817
+ ] as NotificationChannel[];
818
+ const decision = await evaluateSignal(
819
+ makeSchedulerShareSignal({
820
+ attentionHints: {
821
+ requiresAction: false,
822
+ urgency: "medium",
823
+ isAsyncBackground: true,
824
+ visibleInSourceNow: false,
825
+ },
826
+ }),
827
+ available,
828
+ );
829
+
830
+ expect(decision.shouldNotify).toBe(true);
831
+ expect(decision.selectedChannels).toEqual(["vellum"]);
832
+ expect(decision.reasoningSummary).toBe(
833
+ "scheduler requested-message pass-through",
834
+ );
835
+ expect(decision.renderedCopy.vellum?.body).toBe(SCHEDULER_OWNED_REPORT);
836
+ expect(decision.renderedCopy.vellum?.conversationSeedMessage).toBe(
837
+ SCHEDULER_OWNED_REPORT,
838
+ );
839
+ expect(decision.renderedCopy.telegram?.body).toBe(SCHEDULER_OWNED_REPORT);
840
+ expect(decision.renderedCopy.platform?.body).toBe(SCHEDULER_OWNED_REPORT);
841
+ });
842
+
843
+ test("leaves an unowned scheduler requestedMessage on the model path", async () => {
844
+ const previousSendMessage = providerSendMessage;
845
+ let providerCalled = false;
846
+ providerSendMessage = async () => {
847
+ providerCalled = true;
848
+ return {};
849
+ };
850
+
851
+ try {
852
+ const decision = await evaluateSignal(
853
+ makeSchedulerShareSignal({
854
+ contextPayload: {
855
+ requestedMessage: SCHEDULER_OWNED_REPORT,
856
+ requestedTitle: "Your day",
857
+ },
858
+ }),
859
+ ["vellum", "telegram", "platform"] as NotificationChannel[],
860
+ );
861
+
862
+ expect(providerCalled).toBe(true);
863
+ expect(decision.verbatimCopy).toBeUndefined();
864
+ expect(decision.reasoningSummary).not.toBe(
865
+ "scheduler requested-message pass-through",
866
+ );
867
+ } finally {
868
+ providerSendMessage = previousSendMessage;
869
+ }
870
+ });
871
+
872
+ test("leaves scheduler notify-mode without the ownership marker on the model path", async () => {
873
+ const previousSendMessage = providerSendMessage;
874
+ let providerCalled = false;
875
+ providerSendMessage = async () => {
876
+ providerCalled = true;
877
+ return {};
878
+ };
879
+
880
+ try {
881
+ const decision = await evaluateSignal(
882
+ {
883
+ signalId: "sig-schedule-notify-test-1",
884
+ createdAt: Date.now(),
885
+ sourceChannel: "scheduler",
886
+ sourceContextId: "sched-notify-1",
887
+ sourceEventName: "schedule.notify",
888
+ contextPayload: {
889
+ scheduleId: "sched-notify-1",
890
+ label: "Take out the trash",
891
+ message: "Take out the trash",
892
+ },
893
+ attentionHints: {
894
+ requiresAction: true,
895
+ urgency: "high",
896
+ isAsyncBackground: false,
897
+ visibleInSourceNow: false,
898
+ },
899
+ },
900
+ ["vellum", "telegram", "platform"] as NotificationChannel[],
901
+ );
902
+
903
+ expect(providerCalled).toBe(true);
904
+ expect(decision.verbatimCopy).toBeUndefined();
905
+ expect(decision.reasoningSummary).not.toBe(
906
+ "scheduler requested-message pass-through",
907
+ );
908
+ expect(decision.reasoningSummary).not.toBe("schedule_result pass-through");
909
+ } finally {
910
+ providerSendMessage = previousSendMessage;
911
+ }
912
+ });
913
+ });
@@ -833,6 +833,19 @@ function buildPassThroughDecision(params: {
833
833
  return decision;
834
834
  }
835
835
 
836
+ function selectDefaultChannelsByUrgency(
837
+ urgency: NotificationSignal["attentionHints"]["urgency"],
838
+ availableChannels: NotificationChannel[],
839
+ ): NotificationChannel[] {
840
+ const isUrgent = urgency === "critical" || urgency === "high";
841
+ if (isUrgent) {
842
+ return [...availableChannels];
843
+ }
844
+ return availableChannels.includes("vellum")
845
+ ? ["vellum" as NotificationChannel]
846
+ : [];
847
+ }
848
+
836
849
  /**
837
850
  * The deterministic guards every decision passes through once the model,
838
851
  * the assistant-tool pass-through, or the fallback has rendered copy.
@@ -870,14 +883,10 @@ export async function evaluateSignal(
870
883
  );
871
884
  if (signal.sourceChannel === "assistant_tool" && requestedBody) {
872
885
  const payload = signal.contextPayload as Record<string, unknown>;
873
- const isUrgent =
874
- signal.attentionHints.urgency === "critical" ||
875
- signal.attentionHints.urgency === "high";
876
- const defaultChannels: NotificationChannel[] = isUrgent
877
- ? [...availableChannels]
878
- : availableChannels.includes("vellum")
879
- ? ["vellum" as NotificationChannel]
880
- : [];
886
+ const defaultChannels = selectDefaultChannelsByUrgency(
887
+ signal.attentionHints.urgency,
888
+ availableChannels,
889
+ );
881
890
  // Honor `--preferred-channels` as ADDITIVE push targets on top of
882
891
  // the default channel set. The notification center (vellum) is the
883
892
  // always-on canonical inbox; preferred channels add push surfaces
@@ -926,6 +935,32 @@ export async function evaluateSignal(
926
935
  });
927
936
  }
928
937
 
938
+ // Scheduler-owned requested copy: the scheduler already authored the
939
+ // complete message. Ownership requires both the signal source and the
940
+ // payload marker so schedule.result (requestedMessage, no
941
+ // requestedBySource) and notify-mode (message, no requestedBySource)
942
+ // stay on their existing paths. Urgency still chooses channels; every
943
+ // selected channel keeps the producer body.
944
+ const requestedBySource = nonEmpty(
945
+ readPayloadString(signal.contextPayload, "requestedBySource"),
946
+ );
947
+ if (
948
+ signal.sourceChannel === "scheduler" &&
949
+ requestedBySource === "scheduler" &&
950
+ requestedBody
951
+ ) {
952
+ return buildPassThroughDecision({
953
+ signal,
954
+ availableChannels,
955
+ selectedChannels: selectDefaultChannelsByUrgency(
956
+ signal.attentionHints.urgency,
957
+ availableChannels,
958
+ ),
959
+ body: requestedBody,
960
+ reasoningSummary: "scheduler requested-message pass-through",
961
+ });
962
+ }
963
+
929
964
  // Schedule-result pass-through: the body is the run's own reply, which is
930
965
  // the whole point of the notification — a briefing, a digest, a report. The
931
966
  // classifier rewrites bodies into short alerts, which would throw away the
@@ -302,6 +302,25 @@ describe("GET /v1/acp/sessions — merged in-memory + history", () => {
302
302
  ]);
303
303
  });
304
304
 
305
+ test("does not expose snapshot-only history metadata", async () => {
306
+ insertHistoryRow({
307
+ id: "hist-private",
308
+ status: "completed",
309
+ cwd: "/tmp/private-project",
310
+ authErrorCredential: "credential-digest",
311
+ });
312
+
313
+ const handler = getSessionsHandler();
314
+ const body = (await handler({})) as ResponseShape;
315
+ const session = body.sessions.find((entry) => entry.id === "hist-private");
316
+
317
+ expect(session).toBeDefined();
318
+ expect(session).not.toHaveProperty("source");
319
+ expect(session).not.toHaveProperty("resumable");
320
+ expect(session).not.toHaveProperty("cwd");
321
+ expect(session).not.toHaveProperty("authErrorCredential");
322
+ });
323
+
305
324
  test("returns input/output tokens for live and history sessions", async () => {
306
325
  fakeInMemorySessions = [
307
326
  {
@@ -24,7 +24,13 @@ import {
24
24
  AcpResumeError,
25
25
  AcpSessionNotFoundError,
26
26
  } from "../../acp/session-manager.js";
27
- import { type AcpSessionState, isLiveAcpStatus } from "../../acp/types.js";
27
+ import {
28
+ type AcpSessionSnapshot,
29
+ listAcpSessionSnapshots,
30
+ snapshotHistoryRow,
31
+ withCurrentAuthMarkers,
32
+ } from "../../acp/session-snapshot.js";
33
+ import { isLiveAcpStatus } from "../../acp/types.js";
28
34
  import {
29
35
  AcpSessionModelUpdateEventSchema,
30
36
  type AssistantEvent,
@@ -103,11 +109,14 @@ type SessionEntry = z.infer<typeof sessionEntrySchema>;
103
109
  * client has no use for it and no way to resolve the other side of the
104
110
  * comparison, so serving it would only widen what leaves the daemon.
105
111
  */
106
- type MergedSession = SessionEntry & { authErrorCredential?: string };
112
+ type MergedSession = AcpSessionSnapshot;
107
113
 
108
- /** Drop the comparison's own input before the session goes out on the wire. */
114
+ /** Drop internal snapshot metadata before the session goes out on the wire. */
109
115
  function stripMarkerCredential({
110
- authErrorCredential: _dropped,
116
+ authErrorCredential: _credential,
117
+ source: _source,
118
+ resumable: _resumable,
119
+ cwd: _cwd,
111
120
  ...session
112
121
  }: MergedSession): SessionEntry {
113
122
  return session;
@@ -511,7 +520,7 @@ async function listSessions({ queryParams }: RouteHandlerArgs) {
511
520
  return resolvedByAgent.get(agentId);
512
521
  };
513
522
 
514
- const { sessions: merged, sawEveryHistoryRow } = listMergedSessions({
523
+ const { sessions: merged, sawEveryHistoryRow } = listAcpSessionSnapshots({
515
524
  limit,
516
525
  conversationId,
517
526
  });
@@ -552,43 +561,12 @@ async function listSessions({ queryParams }: RouteHandlerArgs) {
552
561
  return { sessions: [...page, stripMarkerCredential(marker)] };
553
562
  }
554
563
 
555
- /**
556
- * Blank the failure code on any session whose marker no longer describes the
557
- * credential its agent would resolve.
558
- *
559
- * This comparison is what retires a Connect card: not a sweep that has to run
560
- * at the right moment, but the marker no longer describing the credential in
561
- * use. Applied after merging rather than inside the query, so live sessions
562
- * and history rows are judged by exactly the same rule.
563
- *
564
- * Resolved per agent, because precedence is per agent: one alias can carry a
565
- * configured token while another falls through to the vault. Memoised across
566
- * the request, since a conversation's marked runs are nearly always one agent
567
- * and each resolution costs a vault read.
568
- */
569
564
  async function withCurrentMarkersOnly(
570
565
  sessions: MergedSession[],
571
566
  resolvedFor: (agentId: string) => Promise<string | undefined>,
572
567
  ): Promise<SessionEntry[]> {
573
- const strip = stripMarkerCredential;
574
- if (!sessions.some((s) => s.authErrorCode !== undefined)) {
575
- return sessions.map(strip);
576
- }
577
- const judged: SessionEntry[] = [];
578
- for (const session of sessions) {
579
- if (session.authErrorCode === undefined) {
580
- judged.push(strip(session));
581
- continue;
582
- }
583
- const current = acpAuthMarkerStillCurrent(
584
- session.authErrorCredential,
585
- await resolvedFor(session.agentId),
586
- );
587
- judged.push(
588
- strip(current ? session : { ...session, authErrorCode: undefined }),
589
- );
590
- }
591
- return judged;
568
+ const judged = await withCurrentAuthMarkers(sessions, resolvedFor);
569
+ return judged.map(stripMarkerCredential);
592
570
  }
593
571
 
594
572
  function bulkDeleteSessions({ queryParams }: RouteHandlerArgs) {
@@ -879,128 +857,6 @@ function parseLimit(raw: string | null | undefined): number {
879
857
  return Math.min(Math.floor(n), MAX_SESSION_LIMIT);
880
858
  }
881
859
 
882
- function listMergedSessions(opts: { limit: number; conversationId?: string }): {
883
- sessions: MergedSession[];
884
- /**
885
- * Whether the history query reached the end of this conversation's rows.
886
- *
887
- * A short read means there is nothing beyond what was returned, which is the
888
- * proof that no marker is hiding outside the page.
889
- */
890
- sawEveryHistoryRow: boolean;
891
- } {
892
- const manager = getAcpSessionManager();
893
- const inMemory = manager.getStatus() as AcpSessionState[];
894
-
895
- const merged = new Map<string, MergedSession>();
896
- for (const s of inMemory) {
897
- if (opts.conversationId && s.parentConversationId !== opts.conversationId) {
898
- continue;
899
- }
900
- merged.set(s.id, {
901
- id: s.id,
902
- agentId: s.agentId,
903
- acpSessionId: s.acpSessionId,
904
- parentConversationId: s.parentConversationId,
905
- status: s.status,
906
- startedAt: s.startedAt,
907
- completedAt: s.completedAt ?? null,
908
- error: s.error ?? null,
909
- stopReason: s.stopReason ?? null,
910
- task: s.task,
911
- parentToolUseId: s.parentToolUseId,
912
- authErrorCode: s.authErrorCode,
913
- authErrorCredential: s.authErrorCredential,
914
- model: s.model,
915
- availableModels: s.availableModels,
916
- modelRevisionEpoch: s.modelRevisionEpoch,
917
- modelRevision: s.modelRevision,
918
- usedTokens: s.latestUsage?.usedTokens,
919
- contextSize: s.latestUsage?.contextSize,
920
- costAmount: s.latestUsage?.costAmount,
921
- costCurrency: s.latestUsage?.costCurrency,
922
- inputTokens: s.latestUsage?.inputTokens,
923
- outputTokens: s.latestUsage?.outputTokens,
924
- eventLog: manager.getBufferedUpdates(s.id),
925
- });
926
- }
927
-
928
- const db = getDb();
929
- const baseQuery = db.select().from(acpSessionHistory);
930
- const filtered = opts.conversationId
931
- ? baseQuery.where(
932
- eq(acpSessionHistory.parentConversationId, opts.conversationId),
933
- )
934
- : baseQuery;
935
- // Fetch only enough rows to fill the requested page after merging with
936
- // in-memory sessions. In-memory entries take precedence on id collision,
937
- // so we pad by the count that survived the conversation filter to
938
- // guarantee we still surface `limit` distinct rows even when every
939
- // in-memory session shadows a DB row — without over-fetching when many
940
- // unrelated sessions are in memory.
941
- const historyLimit = opts.limit + merged.size;
942
- const historyRows = filtered
943
- .orderBy(desc(acpSessionHistory.startedAt))
944
- .limit(historyLimit)
945
- .all();
946
-
947
- for (const row of historyRows) {
948
- if (merged.has(row.id)) {
949
- continue;
950
- }
951
- merged.set(row.id, toMergedSession(row));
952
- }
953
-
954
- return {
955
- sessions: Array.from(merged.values()).sort(
956
- (a, b) => b.startedAt - a.startedAt,
957
- ),
958
- sawEveryHistoryRow: historyRows.length < historyLimit,
959
- };
960
- }
961
-
962
- /** Shape a history row for the response, parsing its stored event log. */
963
- function toMergedSession(
964
- row: typeof acpSessionHistory.$inferSelect,
965
- ): MergedSession {
966
- let eventLog: unknown[] = [];
967
- try {
968
- const parsed = JSON.parse(row.eventLogJson) as unknown;
969
- if (Array.isArray(parsed)) {
970
- eventLog = parsed;
971
- }
972
- } catch (err) {
973
- log.warn(
974
- { id: row.id, err },
975
- "Failed to parse event_log_json for ACP session history row",
976
- );
977
- }
978
- // Rows predating the usage migration carry NULLs for these columns and
979
- // degrade to undefined.
980
- return {
981
- id: row.id,
982
- agentId: row.agentId,
983
- acpSessionId: row.acpSessionId,
984
- parentConversationId: row.parentConversationId,
985
- status: row.status,
986
- startedAt: row.startedAt,
987
- completedAt: row.completedAt,
988
- error: row.error,
989
- stopReason: row.stopReason,
990
- task: row.task ?? undefined,
991
- parentToolUseId: row.parentToolUseId ?? undefined,
992
- authErrorCode: row.authErrorCode ?? undefined,
993
- authErrorCredential: row.authErrorCredential ?? undefined,
994
- usedTokens: row.usedTokens ?? undefined,
995
- contextSize: row.contextSize ?? undefined,
996
- costAmount: row.costAmount ?? undefined,
997
- costCurrency: row.costCurrency ?? undefined,
998
- inputTokens: row.inputTokens ?? undefined,
999
- outputTokens: row.outputTokens ?? undefined,
1000
- eventLog,
1001
- };
1002
- }
1003
-
1004
860
  /**
1005
861
  * Reach past the page for the one marked run a client would restore the card
1006
862
  * from, when the page itself holds none.
@@ -1061,7 +917,7 @@ async function findRecoveryMarker(
1061
917
  .from(acpSessionHistory)
1062
918
  .where(eq(acpSessionHistory.id, marker.id))
1063
919
  .get();
1064
- return row ? toMergedSession(row) : undefined;
920
+ return row ? snapshotHistoryRow(row) : undefined;
1065
921
  }
1066
922
  }
1067
923
  return undefined;
@@ -1,12 +1,12 @@
1
1
  /**
2
- * Tests for the `acp_status` tool's projection of session state.
2
+ * Tests for the `acp_status` tool's live and persisted session projection.
3
3
  *
4
- * The state the manager tracks feeds three surfaces with different budgets:
5
- * the HTTP route, the SSE picker, and this tool, whose output is spent on
6
- * LLM context. Only the fields an agent can act on belong here.
4
+ * The session manager owns live process state. Completed sessions move to
5
+ * durable history, where the status tool must keep them discoverable for
6
+ * follow-up work without making internal lifecycle guards treat them as live.
7
7
  */
8
8
 
9
- import { describe, expect, mock, test } from "bun:test";
9
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
10
10
 
11
11
  import type { AcpSessionState } from "../../acp/types.js";
12
12
  import type { ToolContext } from "../types.js";
@@ -26,24 +26,49 @@ const RUNNING_STATE: AcpSessionState = {
26
26
  ],
27
27
  };
28
28
 
29
- let statusResult: AcpSessionState | AcpSessionState[] = RUNNING_STATE;
29
+ let liveStates: AcpSessionState[] = [];
30
+ let fakeStoredCredential: string | undefined;
30
31
 
31
32
  const realAcpModule = await import("../../acp/index.js");
32
33
  mock.module("../../acp/index.js", () => ({
33
34
  ...realAcpModule,
34
- getAcpSessionManager: () => ({ getStatus: () => statusResult }),
35
+ getAcpSessionManager: () => ({
36
+ getStatus: () => liveStates,
37
+ getBufferedUpdates: () => [],
38
+ }),
35
39
  }));
36
40
 
41
+ const realClaudeOauth = await import("../../acp/acp-claude-oauth.js");
42
+ mock.module("../../acp/acp-claude-oauth.js", () => ({
43
+ ...realClaudeOauth,
44
+ storedClaudeTokenDigest: async () => fakeStoredCredential,
45
+ }));
46
+
47
+ import {
48
+ clearHistory,
49
+ insertHistoryRow,
50
+ } from "../../acp/__tests__/helpers/acp-history-db.js";
51
+ import { claudeTokenDigest } from "../../acp/acp-auth-marker-store.js";
52
+ import { initializeDb } from "../../persistence/db-init.js";
53
+
37
54
  const { executeAcpStatus } = await import("./status.js");
38
55
 
56
+ await initializeDb();
57
+
39
58
  const context = {
40
59
  conversationId: "conv-1",
41
60
  workingDir: "/tmp",
42
61
  } as unknown as ToolContext;
43
62
 
63
+ beforeEach(() => {
64
+ liveStates = [];
65
+ fakeStoredCredential = undefined;
66
+ clearHistory();
67
+ });
68
+
44
69
  describe("executeAcpStatus", () => {
45
- test("a single session reports its model but not the picker", async () => {
46
- statusResult = RUNNING_STATE;
70
+ test("a live session reports its model but not the picker", async () => {
71
+ liveStates = [RUNNING_STATE];
47
72
 
48
73
  const result = await executeAcpStatus({ acp_session_id: "acp-1" }, context);
49
74
 
@@ -62,33 +87,263 @@ describe("executeAcpStatus", () => {
62
87
  expect(result.content).not.toContain("Most capable");
63
88
  });
64
89
 
65
- test("the listing drops the picker from every entry", async () => {
66
- statusResult = [
67
- RUNNING_STATE,
68
- { ...RUNNING_STATE, id: "acp-2", model: "sonnet" },
69
- ];
90
+ test("a cleanly completed resumable session is returned as idle", async () => {
91
+ insertHistoryRow({
92
+ id: "completed-1",
93
+ acpSessionId: "proto-completed-1",
94
+ startedAt: 2000,
95
+ completedAt: 3000,
96
+ status: "completed",
97
+ stopReason: "end_turn",
98
+ cwd: "/tmp/project",
99
+ task: "implement the parser",
100
+ usedTokens: 1200,
101
+ contextSize: 10000,
102
+ });
70
103
 
71
- const result = await executeAcpStatus({}, context);
104
+ const result = await executeAcpStatus(
105
+ { acp_session_id: "completed-1" },
106
+ context,
107
+ );
108
+
109
+ expect(result.isError).toBe(false);
110
+ expect(JSON.parse(result.content)).toEqual({
111
+ id: "completed-1",
112
+ agentId: "claude",
113
+ acpSessionId: "proto-completed-1",
114
+ parentConversationId: "conv-1",
115
+ status: "idle",
116
+ startedAt: 2000,
117
+ completedAt: 3000,
118
+ stopReason: "end_turn",
119
+ task: "implement the parser",
120
+ latestUsage: {
121
+ usedTokens: 1200,
122
+ contextSize: 10000,
123
+ },
124
+ resumable: true,
125
+ lastRunStatus: "completed",
126
+ });
127
+ });
128
+
129
+ test("failed and cancelled history rows keep their terminal status", async () => {
130
+ insertHistoryRow({
131
+ id: "failed-1",
132
+ status: "failed",
133
+ error: "adapter failed",
134
+ cwd: "/tmp/project",
135
+ });
136
+ insertHistoryRow({
137
+ id: "cancelled-1",
138
+ status: "cancelled",
139
+ stopReason: "cancelled",
140
+ cwd: "/tmp/project",
141
+ });
72
142
 
143
+ const failed = await executeAcpStatus(
144
+ { acp_session_id: "failed-1" },
145
+ context,
146
+ );
147
+ const cancelled = await executeAcpStatus(
148
+ { acp_session_id: "cancelled-1" },
149
+ context,
150
+ );
151
+
152
+ expect(JSON.parse(failed.content)).toMatchObject({
153
+ status: "failed",
154
+ resumable: true,
155
+ lastRunStatus: "failed",
156
+ error: "adapter failed",
157
+ });
158
+ expect(JSON.parse(cancelled.content)).toMatchObject({
159
+ status: "cancelled",
160
+ resumable: true,
161
+ lastRunStatus: "cancelled",
162
+ });
163
+ });
164
+
165
+ test("a completed legacy row without cwd stays terminal", async () => {
166
+ insertHistoryRow({ id: "legacy-1", status: "completed", cwd: null });
167
+
168
+ const result = await executeAcpStatus(
169
+ { acp_session_id: "legacy-1" },
170
+ context,
171
+ );
172
+
173
+ expect(JSON.parse(result.content)).toMatchObject({
174
+ status: "completed",
175
+ resumable: false,
176
+ lastRunStatus: "completed",
177
+ });
178
+ });
179
+
180
+ test("a completed run stopped by cancellation stays terminal", async () => {
181
+ insertHistoryRow({
182
+ id: "partial-1",
183
+ status: "completed",
184
+ stopReason: "cancelled",
185
+ cwd: "/tmp/project",
186
+ });
187
+
188
+ const result = await executeAcpStatus(
189
+ { acp_session_id: "partial-1" },
190
+ context,
191
+ );
192
+
193
+ expect(JSON.parse(result.content)).toMatchObject({
194
+ status: "completed",
195
+ resumable: true,
196
+ lastRunStatus: "completed",
197
+ stopReason: "cancelled",
198
+ });
199
+ });
200
+
201
+ test("the listing includes live and persisted idle sessions", async () => {
202
+ liveStates = [RUNNING_STATE];
203
+ insertHistoryRow({
204
+ id: "idle-1",
205
+ startedAt: 2000,
206
+ status: "completed",
207
+ cwd: "/tmp/project",
208
+ });
209
+
210
+ const result = await executeAcpStatus({}, context);
73
211
  const payload = JSON.parse(result.content) as Array<
74
212
  Record<string, unknown>
75
213
  >;
76
- expect(payload).toHaveLength(2);
77
- expect(payload.map((entry) => entry.model)).toEqual([
78
- "claude-opus-4-5",
79
- "sonnet",
214
+
215
+ expect(payload.map((entry) => [entry.id, entry.status])).toEqual([
216
+ ["idle-1", "idle"],
217
+ ["acp-1", "running"],
80
218
  ]);
81
219
  for (const entry of payload) {
82
220
  expect(entry).not.toHaveProperty("availableModels");
221
+ expect(entry).not.toHaveProperty("eventLog");
222
+ expect(entry).not.toHaveProperty("cwd");
223
+ expect(entry).not.toHaveProperty("source");
224
+ expect(entry).not.toHaveProperty("authErrorCredential");
83
225
  }
84
226
  });
85
227
 
86
- test("an empty listing says so rather than rendering an empty array", async () => {
87
- statusResult = [];
228
+ test("live state wins over history for the same id", async () => {
229
+ liveStates = [RUNNING_STATE];
230
+ insertHistoryRow({
231
+ id: RUNNING_STATE.id,
232
+ status: "completed",
233
+ cwd: "/tmp/project",
234
+ });
88
235
 
236
+ const result = await executeAcpStatus(
237
+ { acp_session_id: RUNNING_STATE.id },
238
+ context,
239
+ );
240
+
241
+ expect(JSON.parse(result.content)).toMatchObject({
242
+ id: RUNNING_STATE.id,
243
+ status: "running",
244
+ agentId: RUNNING_STATE.agentId,
245
+ });
246
+ });
247
+
248
+ test("a truly unknown id keeps the not-found error", async () => {
249
+ const result = await executeAcpStatus(
250
+ { acp_session_id: "missing-1" },
251
+ context,
252
+ );
253
+
254
+ expect(result).toEqual({
255
+ content: 'ACP session "missing-1" not found',
256
+ isError: true,
257
+ });
258
+ });
259
+
260
+ test("an empty listing says so rather than rendering an empty array", async () => {
89
261
  const result = await executeAcpStatus({}, context);
90
262
 
91
263
  expect(result.content).toBe("No ACP sessions found.");
92
264
  expect(result.isError).toBe(false);
93
265
  });
266
+
267
+ test("withholds a persisted auth marker after the credential is replaced", async () => {
268
+ const refused = claudeTokenDigest("sk-ant-oat-refused");
269
+ insertHistoryRow({
270
+ id: "auth-failed-1",
271
+ status: "failed",
272
+ error: "authentication required",
273
+ cwd: "/tmp/project",
274
+ authErrorCode: "acp_claude_auth_required",
275
+ authErrorCredential: refused,
276
+ });
277
+ fakeStoredCredential = claudeTokenDigest("sk-ant-oat-replacement");
278
+
279
+ const result = await executeAcpStatus(
280
+ { acp_session_id: "auth-failed-1" },
281
+ context,
282
+ );
283
+
284
+ expect(JSON.parse(result.content)).toMatchObject({
285
+ id: "auth-failed-1",
286
+ status: "failed",
287
+ lastRunStatus: "failed",
288
+ });
289
+ expect(JSON.parse(result.content).authErrorCode).toBeUndefined();
290
+ expect(result.content).not.toContain("authErrorCredential");
291
+ });
292
+
293
+ test("keeps a persisted auth marker while the refused credential is current", async () => {
294
+ const refused = claudeTokenDigest("sk-ant-oat-refused");
295
+ insertHistoryRow({
296
+ id: "auth-failed-2",
297
+ status: "failed",
298
+ cwd: "/tmp/project",
299
+ authErrorCode: "acp_claude_auth_required",
300
+ authErrorCredential: refused,
301
+ });
302
+ fakeStoredCredential = refused;
303
+
304
+ const result = await executeAcpStatus(
305
+ { acp_session_id: "auth-failed-2" },
306
+ context,
307
+ );
308
+
309
+ expect(JSON.parse(result.content)).toMatchObject({
310
+ id: "auth-failed-2",
311
+ status: "failed",
312
+ authErrorCode: "acp_claude_auth_required",
313
+ });
314
+ expect(result.content).not.toContain("authErrorCredential");
315
+ });
316
+
317
+ test("keeps an older live session when 50 newer history rows exist", async () => {
318
+ liveStates = [
319
+ {
320
+ ...RUNNING_STATE,
321
+ id: "old-live",
322
+ startedAt: 1,
323
+ },
324
+ ];
325
+ for (let i = 0; i < 50; i++) {
326
+ insertHistoryRow({
327
+ id: `hist-${i}`,
328
+ acpSessionId: `proto-hist-${i}`,
329
+ startedAt: 1000 + i,
330
+ status: "completed",
331
+ cwd: "/tmp/project",
332
+ });
333
+ }
334
+
335
+ const result = await executeAcpStatus({}, context);
336
+ const payload = JSON.parse(result.content) as Array<{
337
+ id: string;
338
+ status: string;
339
+ }>;
340
+
341
+ expect(payload).toHaveLength(50);
342
+ expect(payload.some((entry) => entry.id === "old-live")).toBe(true);
343
+ expect(payload.find((entry) => entry.id === "old-live")?.status).toBe(
344
+ "running",
345
+ );
346
+ expect(payload[0]?.id).toBe("hist-49");
347
+ expect(payload.some((entry) => entry.id === "hist-0")).toBe(false);
348
+ });
94
349
  });
@@ -1,13 +1,37 @@
1
1
  import { z } from "zod";
2
2
 
3
- import { getAcpSessionManager } from "../../acp/index.js";
4
- import type { AcpSessionState } from "../../acp/types.js";
3
+ import { resolvedClaudeCredentialDigest } from "../../acp/prepare-agent-env.js";
4
+ import {
5
+ type AcpSessionSnapshot,
6
+ getAcpSessionSnapshot,
7
+ listAcpSessionSnapshots,
8
+ withCurrentAuthMarkers,
9
+ } from "../../acp/session-snapshot.js";
5
10
  import {
6
11
  invalidToolInputResult,
7
12
  nullAsOmitted,
8
13
  } from "../shared/zod-tool-schema.js";
9
14
  import type { ToolContext, ToolExecutionResult } from "../types.js";
10
15
 
16
+ const STATUS_LIST_LIMIT = 50;
17
+
18
+ /**
19
+ * Keep every live session visible, then fill remaining capacity from durable
20
+ * history. An older active run must not be displaced by newer historical rows.
21
+ */
22
+ function selectStatusSnapshots(
23
+ sessions: AcpSessionSnapshot[],
24
+ ): AcpSessionSnapshot[] {
25
+ const live = sessions.filter((session) => session.source === "live");
26
+ const history = sessions
27
+ .filter((session) => session.source === "history")
28
+ .sort((a, b) => b.startedAt - a.startedAt);
29
+ const remaining = Math.max(0, STATUS_LIST_LIMIT - live.length);
30
+ return [...live, ...history.slice(0, remaining)].sort(
31
+ (a, b) => b.startedAt - a.startedAt,
32
+ );
33
+ }
34
+
11
35
  /**
12
36
  * Model-input schema, `safeParse`d at the top of {@link executeAcpStatus}.
13
37
  * Same in-tool pattern and TOOLS.json drift guard as the other bundled-skill
@@ -24,33 +48,48 @@ export const acpStatusInputSchema = z.looseObject({
24
48
  * `AcpSessionState` for those surfaces should not reach this one by default.
25
49
  * `model` stays: which model a session is running on is answerable.
26
50
  */
27
- function projectSession(state: AcpSessionState) {
51
+ function projectSession(snapshot: AcpSessionSnapshot) {
52
+ const fromHistory = snapshot.source === "history";
53
+ const idle =
54
+ fromHistory &&
55
+ snapshot.status === "completed" &&
56
+ snapshot.stopReason !== "cancelled" &&
57
+ snapshot.resumable;
58
+ const latestUsage =
59
+ snapshot.usedTokens !== undefined && snapshot.contextSize !== undefined
60
+ ? {
61
+ usedTokens: snapshot.usedTokens,
62
+ contextSize: snapshot.contextSize,
63
+ costAmount: snapshot.costAmount,
64
+ costCurrency: snapshot.costCurrency,
65
+ inputTokens: snapshot.inputTokens,
66
+ outputTokens: snapshot.outputTokens,
67
+ }
68
+ : undefined;
28
69
  return {
29
- id: state.id,
30
- agentId: state.agentId,
31
- acpSessionId: state.acpSessionId,
32
- parentConversationId: state.parentConversationId,
33
- status: state.status,
34
- startedAt: state.startedAt,
35
- completedAt: state.completedAt,
36
- error: state.error,
37
- stopReason: state.stopReason,
38
- task: state.task,
39
- parentToolUseId: state.parentToolUseId,
40
- authErrorCode: state.authErrorCode,
41
- authErrorCredential: state.authErrorCredential,
42
- latestUsage: state.latestUsage,
43
- model: state.model,
70
+ id: snapshot.id,
71
+ agentId: snapshot.agentId,
72
+ acpSessionId: snapshot.acpSessionId,
73
+ parentConversationId: snapshot.parentConversationId,
74
+ status: idle ? "idle" : snapshot.status,
75
+ startedAt: snapshot.startedAt,
76
+ completedAt: snapshot.completedAt ?? undefined,
77
+ error: snapshot.error ?? undefined,
78
+ stopReason: snapshot.stopReason ?? undefined,
79
+ task: snapshot.task,
80
+ parentToolUseId: snapshot.parentToolUseId,
81
+ authErrorCode: snapshot.authErrorCode,
82
+ latestUsage,
83
+ model: snapshot.model,
84
+ ...(fromHistory
85
+ ? {
86
+ resumable: snapshot.resumable,
87
+ lastRunStatus: snapshot.status,
88
+ }
89
+ : {}),
44
90
  };
45
91
  }
46
92
 
47
- /** Projects either shape `getStatus` answers with. */
48
- function projectStatus(status: AcpSessionState | AcpSessionState[]): unknown {
49
- return Array.isArray(status)
50
- ? status.map(projectSession)
51
- : projectSession(status);
52
- }
53
-
54
93
  export async function executeAcpStatus(
55
94
  input: Record<string, unknown>,
56
95
  _context: ToolContext,
@@ -60,24 +99,51 @@ export async function executeAcpStatus(
60
99
  return invalidToolInputResult("acp_status", parsedInput.error);
61
100
  }
62
101
  const acpSessionId = parsedInput.data.acp_session_id;
63
- const manager = getAcpSessionManager();
64
-
65
102
  try {
66
103
  if (acpSessionId) {
104
+ const snapshot = getAcpSessionSnapshot(acpSessionId, {
105
+ includeEventLog: false,
106
+ });
107
+ if (!snapshot) {
108
+ return {
109
+ content: `ACP session "${acpSessionId}" not found`,
110
+ isError: true,
111
+ };
112
+ }
113
+ const judged = (
114
+ await withCurrentAuthMarkers(
115
+ [snapshot],
116
+ resolvedClaudeCredentialDigest,
117
+ )
118
+ )[0];
119
+ if (!judged) {
120
+ return {
121
+ content: `ACP session "${acpSessionId}" not found`,
122
+ isError: true,
123
+ };
124
+ }
67
125
  return {
68
- content: JSON.stringify(projectStatus(manager.getStatus(acpSessionId))),
126
+ content: JSON.stringify(projectSession(judged)),
69
127
  isError: false,
70
128
  };
71
129
  }
72
130
 
73
- // List all sessions.
74
- const allStates = manager.getStatus();
75
- if (Array.isArray(allStates) && allStates.length === 0) {
131
+ const snapshots = selectStatusSnapshots(
132
+ listAcpSessionSnapshots({
133
+ limit: STATUS_LIST_LIMIT,
134
+ includeEventLog: false,
135
+ }).sessions,
136
+ );
137
+ if (snapshots.length === 0) {
76
138
  return { content: "No ACP sessions found.", isError: false };
77
139
  }
78
140
 
141
+ const judged = await withCurrentAuthMarkers(
142
+ snapshots,
143
+ resolvedClaudeCredentialDigest,
144
+ );
79
145
  return {
80
- content: JSON.stringify(projectStatus(allStates)),
146
+ content: JSON.stringify(judged.map(projectSession)),
81
147
  isError: false,
82
148
  };
83
149
  } catch (err) {