feat(sessions): expose label in sessions.list and support label lookup in sessions_send
- Add `label` field to session entries and expose it in `sessions.list`
- Display label column in the web UI sessions table
- Support `label` parameter in `sessions_send` for lookup by label instead of sessionKey
- `sessions.patch`: Accept and store `label` field
- `sessions.list`: Return `label` in session entries
- `sessions_spawn`: Pass label through to registry and announce flow
- `sessions_send`: Accept optional `label` param, lookup session by label if sessionKey not provided
- `agent` method: Accept `label` and `spawnedBy` params (stored in session entry)
- Add `label` column to sessions table in web UI
- Changed session store writes to merge with existing entry (`{ ...existing, ...new }`)
to preserve fields like `label` that might be set separately
We attempted to implement label persistence "properly" by passing the label
through the `agent` call and storing it during session initialization. However,
the auto-reply flow has multiple write points that overwrite the session entry,
and making all of them merge-aware proved unreliable.
The working solution patches the label in the `finally` block of
`runSubagentAnnounceFlow`, after all other session writes complete.
This is a workaround but robust - the patch happens at the very end,
just before potential cleanup.
A future refactor could make session writes consistently merge-based,
which would allow the cleaner approach of setting label at spawn time.
```typescript
// Spawn with label
sessions_spawn({ task: "...", label: "my-worker" })
// Later, find by label
sessions_send({ label: "my-worker", message: "continue..." })
// Or use sessions_list to see labels
sessions_list() // includes label field in response
```
This commit is contained in:
@@ -225,6 +225,8 @@ export const AgentParamsSchema = Type.Object(
|
||||
lane: Type.Optional(Type.String()),
|
||||
extraSystemPrompt: Type.Optional(Type.String()),
|
||||
idempotencyKey: NonEmptyString,
|
||||
label: Type.Optional(Type.String()),
|
||||
spawnedBy: Type.Optional(Type.String()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
@@ -322,6 +324,7 @@ export const SessionsListParamsSchema = Type.Object(
|
||||
export const SessionsPatchParamsSchema = Type.Object(
|
||||
{
|
||||
key: NonEmptyString,
|
||||
label: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||
thinkingLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||
verboseLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||
reasoningLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||
|
||||
@@ -397,6 +397,25 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) {
|
||||
}
|
||||
}
|
||||
|
||||
if ("label" in p) {
|
||||
const raw = p.label;
|
||||
if (raw === null) {
|
||||
delete next.label;
|
||||
} else if (raw !== undefined) {
|
||||
const trimmed = String(raw).trim();
|
||||
if (!trimmed) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: ErrorCodes.INVALID_REQUEST,
|
||||
message: "invalid label: empty",
|
||||
},
|
||||
};
|
||||
}
|
||||
next.label = trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
if ("thinkingLevel" in p) {
|
||||
const raw = p.thinkingLevel;
|
||||
if (raw === null) {
|
||||
@@ -628,6 +647,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) {
|
||||
model: entry?.model,
|
||||
contextTokens: entry?.contextTokens,
|
||||
sendPolicy: entry?.sendPolicy,
|
||||
label: entry?.label,
|
||||
displayName: entry?.displayName,
|
||||
chatType: entry?.chatType,
|
||||
provider: entry?.provider,
|
||||
|
||||
@@ -52,6 +52,8 @@ export const agentHandlers: GatewayRequestHandlers = {
|
||||
extraSystemPrompt?: string;
|
||||
idempotencyKey: string;
|
||||
timeout?: number;
|
||||
label?: string;
|
||||
spawnedBy?: string;
|
||||
};
|
||||
const idem = request.idempotencyKey;
|
||||
const cached = context.dedupe.get(`agent:${idem}`);
|
||||
@@ -78,6 +80,8 @@ export const agentHandlers: GatewayRequestHandlers = {
|
||||
cfgForAgent = cfg;
|
||||
const now = Date.now();
|
||||
const sessionId = entry?.sessionId ?? randomUUID();
|
||||
const labelValue = request.label?.trim() || entry?.label;
|
||||
const spawnedByValue = request.spawnedBy?.trim() || entry?.spawnedBy;
|
||||
const nextEntry: SessionEntry = {
|
||||
sessionId,
|
||||
updatedAt: now,
|
||||
@@ -91,6 +95,8 @@ export const agentHandlers: GatewayRequestHandlers = {
|
||||
lastTo: entry?.lastTo,
|
||||
modelOverride: entry?.modelOverride,
|
||||
providerOverride: entry?.providerOverride,
|
||||
label: labelValue,
|
||||
spawnedBy: spawnedByValue,
|
||||
};
|
||||
sessionEntry = nextEntry;
|
||||
const sendPolicy = resolveSendPolicy({
|
||||
|
||||
@@ -169,6 +169,24 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
||||
}
|
||||
}
|
||||
|
||||
if ("label" in p) {
|
||||
const raw = p.label;
|
||||
if (raw === null) {
|
||||
delete next.label;
|
||||
} else if (raw !== undefined) {
|
||||
const trimmed = String(raw).trim();
|
||||
if (!trimmed) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, "invalid label: empty"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
next.label = trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
if ("thinkingLevel" in p) {
|
||||
const raw = p.thinkingLevel;
|
||||
if (raw === null) {
|
||||
@@ -422,6 +440,7 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
||||
model: entry?.model,
|
||||
contextTokens: entry?.contextTokens,
|
||||
sendPolicy: entry?.sendPolicy,
|
||||
label: entry?.label,
|
||||
lastProvider: entry?.lastProvider,
|
||||
lastTo: entry?.lastTo,
|
||||
skillsSnapshot: entry?.skillsSnapshot,
|
||||
|
||||
@@ -148,12 +148,23 @@ describe("gateway server sessions", () => {
|
||||
expect(sendPolicyPatched.ok).toBe(true);
|
||||
expect(sendPolicyPatched.payload?.entry.sendPolicy).toBe("deny");
|
||||
|
||||
const labelPatched = await rpcReq<{
|
||||
ok: true;
|
||||
entry: { label?: string };
|
||||
}>(ws, "sessions.patch", {
|
||||
key: "agent:main:subagent:one",
|
||||
label: "Briefing",
|
||||
});
|
||||
expect(labelPatched.ok).toBe(true);
|
||||
expect(labelPatched.payload?.entry.label).toBe("Briefing");
|
||||
|
||||
const list2 = await rpcReq<{
|
||||
sessions: Array<{
|
||||
key: string;
|
||||
thinkingLevel?: string;
|
||||
verboseLevel?: string;
|
||||
sendPolicy?: string;
|
||||
label?: string;
|
||||
}>;
|
||||
}>(ws, "sessions.list", {});
|
||||
expect(list2.ok).toBe(true);
|
||||
@@ -163,6 +174,10 @@ describe("gateway server sessions", () => {
|
||||
expect(main2?.thinkingLevel).toBe("medium");
|
||||
expect(main2?.verboseLevel).toBeUndefined();
|
||||
expect(main2?.sendPolicy).toBe("deny");
|
||||
const subagent = list2.payload?.sessions.find(
|
||||
(s) => s.key === "agent:main:subagent:one",
|
||||
);
|
||||
expect(subagent?.label).toBe("Briefing");
|
||||
|
||||
const spawnedOnly = await rpcReq<{
|
||||
sessions: Array<{ key: string }>;
|
||||
|
||||
@@ -34,6 +34,7 @@ export type GatewaySessionsDefaults = {
|
||||
export type GatewaySessionRow = {
|
||||
key: string;
|
||||
kind: "direct" | "group" | "global" | "unknown";
|
||||
label?: string;
|
||||
displayName?: string;
|
||||
provider?: string;
|
||||
subject?: string;
|
||||
@@ -485,6 +486,7 @@ export function listSessionsFromStore(params: {
|
||||
return {
|
||||
key,
|
||||
kind: classifySessionKey(key, entry),
|
||||
label: entry?.label,
|
||||
displayName,
|
||||
provider,
|
||||
subject,
|
||||
|
||||
Reference in New Issue
Block a user