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

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.
@@ -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) {