Skip to content

Commit 56e77f6

Browse files
committed
fix: sessions label lookup and persistence (#570) (thanks @azade-c)
1 parent e24e0cf commit 56e77f6

7 files changed

Lines changed: 122 additions & 68 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
- Control UI: logs tab opens at the newest entries (bottom).
4545
- Control UI: add Docs link, remove chat composer divider, and add New session button.
4646
- Control UI: link sessions list to chat view. (#471) — thanks @HazAT
47+
- Sessions: support session `label` in store/list/UI and allow `sessions_send` lookup by label. (#570) — thanks @azade-c
4748
- Control UI: show/patch per-session reasoning level and render extracted reasoning in chat.
4849
- Control UI: queue outgoing chat messages, add Enter-to-send, and show queued items. (#527) — thanks @YuriNachos
4950
- Control UI: drop explicit `ui:install` step; `ui:build` now auto-installs UI deps (docs + update flow).

src/agents/subagent-registry.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ function ensureListener() {
8484
? (evt.data.endedAt as number)
8585
: Date.now();
8686
entry.endedAt = endedAt;
87-
87+
8888
if (!beginSubagentAnnounce(evt.runId)) {
8989
if (entry.cleanup === "delete") {
9090
subagentRuns.delete(evt.runId);

src/agents/tools/sessions-send-tool.ts

Lines changed: 112 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,24 @@ import {
2929
resolvePingPongTurns,
3030
} from "./sessions-send-helpers.js";
3131

32-
const SessionsSendToolSchema = Type.Object({
33-
sessionKey: Type.Optional(Type.String()),
34-
label: Type.Optional(Type.String()),
35-
message: Type.String(),
36-
timeoutSeconds: Type.Optional(Type.Integer({ minimum: 0 })),
37-
});
32+
const SessionsSendToolSchema = Type.Union([
33+
Type.Object(
34+
{
35+
sessionKey: Type.String(),
36+
message: Type.String(),
37+
timeoutSeconds: Type.Optional(Type.Integer({ minimum: 0 })),
38+
},
39+
{ additionalProperties: false },
40+
),
41+
Type.Object(
42+
{
43+
label: Type.String(),
44+
message: Type.String(),
45+
timeoutSeconds: Type.Optional(Type.Integer({ minimum: 0 })),
46+
},
47+
{ additionalProperties: false },
48+
),
49+
]);
3850

3951
export function createSessionsSendTool(opts?: {
4052
agentSessionKey?: string;
@@ -49,83 +61,124 @@ export function createSessionsSendTool(opts?: {
4961
parameters: SessionsSendToolSchema,
5062
execute: async (_toolCallId, args) => {
5163
const params = args as Record<string, unknown>;
52-
let sessionKey = readStringParam(params, "sessionKey");
53-
const labelParam = readStringParam(params, "label");
5464
const message = readStringParam(params, "message", { required: true });
5565
const cfg = loadConfig();
66+
const { mainKey, alias } = resolveMainSessionAlias(cfg);
67+
const visibility =
68+
cfg.agents?.defaults?.sandbox?.sessionToolsVisibility ?? "spawned";
69+
const requesterInternalKey =
70+
typeof opts?.agentSessionKey === "string" && opts.agentSessionKey.trim()
71+
? resolveInternalSessionKey({
72+
key: opts.agentSessionKey,
73+
alias,
74+
mainKey,
75+
})
76+
: undefined;
77+
const restrictToSpawned =
78+
opts?.sandboxed === true &&
79+
visibility === "spawned" &&
80+
requesterInternalKey &&
81+
!isSubagentSessionKey(requesterInternalKey);
5682

57-
// Lookup by label if sessionKey not provided
58-
if (!sessionKey && labelParam) {
59-
const listResult = (await callGateway({
83+
const sessionKeyParam = readStringParam(params, "sessionKey");
84+
const labelParam = readStringParam(params, "label");
85+
if (sessionKeyParam && labelParam) {
86+
return jsonResult({
87+
runId: crypto.randomUUID(),
88+
status: "error",
89+
error: "Provide either sessionKey or label (not both).",
90+
});
91+
}
92+
93+
const listSessions = async (listParams: Record<string, unknown>) => {
94+
const result = (await callGateway({
6095
method: "sessions.list",
61-
params: { activeMinutes: 1440 }, // Last 24h
96+
params: listParams,
6297
timeoutMs: 10_000,
63-
})) as { sessions?: Array<{ key: string; label?: string }> };
64-
const match = listResult.sessions?.find(
65-
(s) => s.label === labelParam,
66-
);
67-
if (!match) {
98+
})) as { sessions?: Array<Record<string, unknown>> };
99+
return Array.isArray(result?.sessions) ? result.sessions : [];
100+
};
101+
102+
const activeMinutes = 24 * 60;
103+
const visibleSessions = restrictToSpawned
104+
? await listSessions({
105+
activeMinutes,
106+
includeGlobal: false,
107+
includeUnknown: false,
108+
limit: 500,
109+
spawnedBy: requesterInternalKey,
110+
})
111+
: undefined;
112+
113+
let sessionKey = sessionKeyParam;
114+
if (!sessionKey && labelParam) {
115+
const sessions =
116+
visibleSessions ??
117+
(await listSessions({
118+
activeMinutes,
119+
includeGlobal: false,
120+
includeUnknown: false,
121+
limit: 500,
122+
}));
123+
const matches = sessions.filter((entry) => {
124+
const label =
125+
typeof entry?.label === "string" ? entry.label : undefined;
126+
return label === labelParam;
127+
});
128+
if (matches.length === 0) {
129+
if (restrictToSpawned) {
130+
return jsonResult({
131+
runId: crypto.randomUUID(),
132+
status: "forbidden",
133+
error: `Session not visible from this sandboxed agent session: label=${labelParam}`,
134+
});
135+
}
68136
return jsonResult({
137+
runId: crypto.randomUUID(),
69138
status: "error",
70139
error: `No session found with label: ${labelParam}`,
71140
});
72141
}
73-
sessionKey = match.key;
142+
if (matches.length > 1) {
143+
const keys = matches
144+
.map((entry) => (typeof entry?.key === "string" ? entry.key : ""))
145+
.filter(Boolean)
146+
.join(", ");
147+
return jsonResult({
148+
runId: crypto.randomUUID(),
149+
status: "error",
150+
error: `Multiple sessions found with label: ${labelParam}${keys ? ` (${keys})` : ""}`,
151+
});
152+
}
153+
const key = matches[0]?.key;
154+
if (typeof key !== "string" || !key.trim()) {
155+
return jsonResult({
156+
runId: crypto.randomUUID(),
157+
status: "error",
158+
error: `Invalid session entry for label: ${labelParam}`,
159+
});
160+
}
161+
sessionKey = key;
74162
}
75163

76164
if (!sessionKey) {
77165
return jsonResult({
166+
runId: crypto.randomUUID(),
78167
status: "error",
79168
error: "Either sessionKey or label is required",
80169
});
81170
}
82-
const { mainKey, alias } = resolveMainSessionAlias(cfg);
83-
const visibility =
84-
cfg.agents?.defaults?.sandbox?.sessionToolsVisibility ?? "spawned";
85-
const requesterInternalKey =
86-
typeof opts?.agentSessionKey === "string" && opts.agentSessionKey.trim()
87-
? resolveInternalSessionKey({
88-
key: opts.agentSessionKey,
89-
alias,
90-
mainKey,
91-
})
92-
: undefined;
171+
93172
const resolvedKey = resolveInternalSessionKey({
94173
key: sessionKey,
95174
alias,
96175
mainKey,
97176
});
98-
const restrictToSpawned =
99-
opts?.sandboxed === true &&
100-
visibility === "spawned" &&
101-
requesterInternalKey &&
102-
!isSubagentSessionKey(requesterInternalKey);
177+
103178
if (restrictToSpawned) {
104-
try {
105-
const list = (await callGateway({
106-
method: "sessions.list",
107-
params: {
108-
includeGlobal: false,
109-
includeUnknown: false,
110-
limit: 500,
111-
spawnedBy: requesterInternalKey,
112-
},
113-
})) as { sessions?: Array<Record<string, unknown>> };
114-
const sessions = Array.isArray(list?.sessions) ? list.sessions : [];
115-
const ok = sessions.some((entry) => entry?.key === resolvedKey);
116-
if (!ok) {
117-
return jsonResult({
118-
runId: crypto.randomUUID(),
119-
status: "forbidden",
120-
error: `Session not visible from this sandboxed agent session: ${sessionKey}`,
121-
sessionKey: resolveDisplaySessionKey({
122-
key: sessionKey,
123-
alias,
124-
mainKey,
125-
}),
126-
});
127-
}
128-
} catch {
179+
const sessions = visibleSessions ?? [];
180+
const ok = sessions.some((entry) => entry?.key === resolvedKey);
181+
if (!ok) {
129182
return jsonResult({
130183
runId: crypto.randomUUID(),
131184
status: "forbidden",

src/auto-reply/reply/agent-runner.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -271,7 +271,7 @@ export async function runReplyAgent(params: {
271271
if (steered && !shouldFollowup) {
272272
if (sessionEntry && sessionStore && sessionKey) {
273273
sessionEntry.updatedAt = Date.now();
274-
sessionStore[sessionKey] = { ...sessionStore[sessionKey], ...sessionEntry };
274+
sessionStore[sessionKey] = sessionEntry;
275275
if (storePath) {
276276
await saveSessionStore(storePath, sessionStore);
277277
}
@@ -285,7 +285,7 @@ export async function runReplyAgent(params: {
285285
enqueueFollowupRun(queueKey, followupRun, resolvedQueue);
286286
if (sessionEntry && sessionStore && sessionKey) {
287287
sessionEntry.updatedAt = Date.now();
288-
sessionStore[sessionKey] = { ...sessionStore[sessionKey], ...sessionEntry };
288+
sessionStore[sessionKey] = sessionEntry;
289289
if (storePath) {
290290
await saveSessionStore(storePath, sessionStore);
291291
}
@@ -674,7 +674,7 @@ export async function runReplyAgent(params: {
674674
) {
675675
sessionEntry.groupActivationNeedsSystemIntro = false;
676676
sessionEntry.updatedAt = Date.now();
677-
sessionStore[sessionKey] = { ...sessionStore[sessionKey], ...sessionEntry };
677+
sessionStore[sessionKey] = sessionEntry;
678678
if (storePath) {
679679
await saveSessionStore(storePath, sessionStore);
680680
}

src/auto-reply/reply/directive-handling.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -880,7 +880,7 @@ export async function handleDirectiveOnly(params: {
880880
}
881881
}
882882
sessionEntry.updatedAt = Date.now();
883-
sessionStore[sessionKey] = { ...sessionStore[sessionKey], ...sessionEntry };
883+
sessionStore[sessionKey] = sessionEntry;
884884
if (storePath) {
885885
await saveSessionStore(storePath, sessionStore);
886886
}
@@ -1099,7 +1099,7 @@ export async function persistInlineDirectives(params: {
10991099
}
11001100
if (updated) {
11011101
sessionEntry.updatedAt = Date.now();
1102-
sessionStore[sessionKey] = { ...sessionStore[sessionKey], ...sessionEntry };
1102+
sessionStore[sessionKey] = sessionEntry;
11031103
if (storePath) {
11041104
await saveSessionStore(storePath, sessionStore);
11051105
}

src/auto-reply/reply/model-selection.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ export async function createModelSelectionState(params: {
9595
delete sessionEntry.providerOverride;
9696
delete sessionEntry.modelOverride;
9797
sessionEntry.updatedAt = Date.now();
98-
sessionStore[sessionKey] = { ...sessionStore[sessionKey], ...sessionEntry };
98+
sessionStore[sessionKey] = sessionEntry;
9999
if (storePath) {
100100
await saveSessionStore(storePath, sessionStore);
101101
}
@@ -129,7 +129,7 @@ export async function createModelSelectionState(params: {
129129
if (!profile || profile.provider !== provider) {
130130
delete sessionEntry.authProfileOverride;
131131
sessionEntry.updatedAt = Date.now();
132-
sessionStore[sessionKey] = { ...sessionStore[sessionKey], ...sessionEntry };
132+
sessionStore[sessionKey] = sessionEntry;
133133
if (storePath) {
134134
await saveSessionStore(storePath, sessionStore);
135135
}

src/gateway/server.sessions-send.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ describe("sessions_send label lookup", () => {
173173
};
174174
expect(details.status).toBe("ok");
175175
expect(details.reply).toBe("labeled response");
176-
expect(details.sessionKey).toBe("test-labeled-session");
176+
expect(details.sessionKey).toBe("agent:main:test-labeled-session");
177177
} finally {
178178
if (prevPort === undefined) {
179179
delete process.env.CLAWDBOT_GATEWAY_PORT;

0 commit comments

Comments
 (0)