@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 +1 -1
- package/src/acp/session-snapshot.ts +260 -0
- package/src/config/bundled-skills/acp/SKILL.md +1 -1
- package/src/config/bundled-skills/acp/TOOLS.json +2 -2
- package/src/notifications/__tests__/decision-engine.test.ts +175 -0
- package/src/notifications/decision-engine.ts +43 -8
- package/src/runtime/routes/__tests__/acp-routes.test.ts +19 -0
- package/src/runtime/routes/acp-routes.ts +17 -161
- package/src/tools/acp/status.test.ts +276 -21
- package/src/tools/acp/status.ts +98 -32
package/package.json
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
874
|
-
signal.attentionHints.urgency
|
|
875
|
-
|
|
876
|
-
|
|
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 {
|
|
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 =
|
|
112
|
+
type MergedSession = AcpSessionSnapshot;
|
|
107
113
|
|
|
108
|
-
/** Drop
|
|
114
|
+
/** Drop internal snapshot metadata before the session goes out on the wire. */
|
|
109
115
|
function stripMarkerCredential({
|
|
110
|
-
authErrorCredential:
|
|
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 } =
|
|
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
|
|
574
|
-
|
|
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 ?
|
|
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
|
|
2
|
+
* Tests for the `acp_status` tool's live and persisted session projection.
|
|
3
3
|
*
|
|
4
|
-
* The
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
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: () => ({
|
|
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
|
|
46
|
-
|
|
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("
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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(
|
|
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
|
-
|
|
77
|
-
expect(payload.map((entry) => entry.
|
|
78
|
-
"
|
|
79
|
-
"
|
|
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("
|
|
87
|
-
|
|
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
|
});
|
package/src/tools/acp/status.ts
CHANGED
|
@@ -1,13 +1,37 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
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(
|
|
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:
|
|
30
|
-
agentId:
|
|
31
|
-
acpSessionId:
|
|
32
|
-
parentConversationId:
|
|
33
|
-
status:
|
|
34
|
-
startedAt:
|
|
35
|
-
completedAt:
|
|
36
|
-
error:
|
|
37
|
-
stopReason:
|
|
38
|
-
task:
|
|
39
|
-
parentToolUseId:
|
|
40
|
-
authErrorCode:
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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(
|
|
126
|
+
content: JSON.stringify(projectSession(judged)),
|
|
69
127
|
isError: false,
|
|
70
128
|
};
|
|
71
129
|
}
|
|
72
130
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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(
|
|
146
|
+
content: JSON.stringify(judged.map(projectSession)),
|
|
81
147
|
isError: false,
|
|
82
148
|
};
|
|
83
149
|
} catch (err) {
|