From 10efb7f221acce995c2e888cb53eb2e92bbc5d86 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Fri, 3 Jul 2026 10:51:45 +0200 Subject: [PATCH] fix(sessions): name relay handoffs deterministically --- .changeset/relay-session-names.md | 5 +++ src/server/sessions/piSessionService.ts | 9 +++++- .../sessions/sessionNameGenerator.test.ts | 17 +++++++++- src/server/sessions/sessionNameGenerator.ts | 32 +++++++++++++++++++ 4 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 .changeset/relay-session-names.md diff --git a/.changeset/relay-session-names.md b/.changeset/relay-session-names.md new file mode 100644 index 0000000..0d0275a --- /dev/null +++ b/.changeset/relay-session-names.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Name Relay handoff sessions deterministically from their relay name and leg number. diff --git a/src/server/sessions/piSessionService.ts b/src/server/sessions/piSessionService.ts index 9d59b66..fa5f783 100644 --- a/src/server/sessions/piSessionService.ts +++ b/src/server/sessions/piSessionService.ts @@ -24,7 +24,7 @@ import { SessionArchiveStore, type ArchivedSessionRecord, type ArchiveSessionInp import { findArchiveCandidateByIdOrPrefix, planSessionArchiveTree, type SessionArchiveTreeCandidate } from "./sessionArchiveTree.js"; import type { ActiveSession } from "./sessionRuntimeStore.js"; import type { AuthChange } from "./authService.js"; -import { fallbackSessionName, generateShortSessionName } from "./sessionNameGenerator.js"; +import { deterministicSessionName, fallbackSessionName, generateShortSessionName } from "./sessionNameGenerator.js"; import { computeEditPreview, type EditPreviewResult } from "./editPreview.js"; import { createPiSessionManagerGateway } from "./piSessionManagerGateway.js"; import { attachmentsToInlineImages, saveAttachmentsToWorkspace } from "./attachmentService.js"; @@ -1706,6 +1706,13 @@ export class PiSessionService { private maybeGenerateSessionName(session: PiAgentSession, firstMessage: string): void { if (session.sessionName !== undefined || session.messages.length !== 0 || session.isStreaming || session.isCompacting) return; + + const deterministicName = deterministicSessionName(firstMessage); + if (deterministicName !== undefined) { + this.applyGeneratedSessionName(session, deterministicName); + return; + } + const model = session.model; if (model === undefined) return; diff --git a/src/server/sessions/sessionNameGenerator.test.ts b/src/server/sessions/sessionNameGenerator.test.ts index 12078cb..01296ca 100644 --- a/src/server/sessions/sessionNameGenerator.test.ts +++ b/src/server/sessions/sessionNameGenerator.test.ts @@ -2,7 +2,7 @@ import type { Api, AssistantMessage, Model } from "@earendil-works/pi-ai"; import { createAssistantMessageEventStream } from "@earendil-works/pi-ai"; import type { StreamFn } from "@earendil-works/pi-agent-core"; import { describe, expect, it } from "vitest"; -import { cleanSessionName, fallbackSessionName, generateShortSessionName } from "./sessionNameGenerator.js"; +import { cleanSessionName, deterministicSessionName, fallbackSessionName, generateShortSessionName } from "./sessionNameGenerator.js"; function fakeModel(): Model { return { id: "fake-model", name: "Fake Model", api: "anthropic-messages", provider: "anthropic", baseUrl: "https://example.test", reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 1000, maxTokens: 100 }; @@ -69,6 +69,21 @@ describe("sessionNameGenerator", () => { expect(cleanSessionName('Title: "Fix Session Naming."\nextra')).toBe("Fix Session Naming"); }); + it("builds deterministic names for relay handoff prompts", () => { + expect(deterministicSessionName('Relay "handoff-check" leg 2 begins now.\n\nYou are the next runner.')) + .toBe("Relay handoff-check leg 2"); + }); + + it("preserves the relay leg when truncating deterministic relay names", () => { + expect(deterministicSessionName('Relay "very-long-relay-name-that-would-otherwise-push-the-leg-number-out-of-view" leg 42 begins now.')) + .toBe("Relay very-long-relay-name-that-would-otherwise-push leg 42"); + }); + + it("does not build deterministic names for non-canonical relay prompts", () => { + expect(deterministicSessionName('You are continuing Relay "handoff-check" under the Relay method.')) + .toBeUndefined(); + }); + it("builds a concise fallback from the first request", () => { expect(fallbackSessionName("Seems like auto name for sessions is not working, I still get the first message as a name.")) .toBe("Seems like auto name for sessions"); diff --git a/src/server/sessions/sessionNameGenerator.ts b/src/server/sessions/sessionNameGenerator.ts index b95a5d7..f0bf573 100644 --- a/src/server/sessions/sessionNameGenerator.ts +++ b/src/server/sessions/sessionNameGenerator.ts @@ -5,6 +5,13 @@ const SESSION_NAME_TIMEOUT_MS = 10_000; const SESSION_NAME_MAX_INPUT_CHARS = 4_000; const SESSION_NAME_MAX_LENGTH = 60; const FALLBACK_SESSION_NAME_MAX_WORDS = 6; +const RELAY_HANDOFF_FIRST_LINE = /^Relay\s+"([^"\n]+)"\s+leg\s+(\d+)\s+begins now\.?\s*(?:\n|$)/; + +export function deterministicSessionName(firstMessage: unknown): string | undefined { + if (typeof firstMessage !== "string") return undefined; + + return relayHandoffSessionName(firstMessage.trimStart()); +} export async function generateShortSessionName(streamFn: StreamFn, model: Model, firstMessage: string): Promise { const stream = await streamFn( @@ -59,6 +66,31 @@ export function cleanSessionName(value: string): string | undefined { return title === "" ? undefined : title; } +function relayHandoffSessionName(firstMessage: string): string | undefined { + const match = RELAY_HANDOFF_FIRST_LINE.exec(firstMessage); + if (match === null) return undefined; + + const relayName = match[1]?.replace(/\s+/g, " ").trim(); + const legNumber = match[2]; + if (relayName === undefined || relayName === "" || legNumber === undefined) return undefined; + + return cleanSessionName(formatRelaySessionName(relayName, legNumber)); +} + +function formatRelaySessionName(relayName: string, legNumber: string): string { + const prefix = "Relay "; + const suffix = ` leg ${legNumber}`; + const maxRelayNameLength = Math.max(1, SESSION_NAME_MAX_LENGTH - prefix.length - suffix.length); + const displayedRelayName = truncateRelayName(relayName, maxRelayNameLength); + return `${prefix}${displayedRelayName}${suffix}`; +} + +function truncateRelayName(relayName: string, maxLength: number): string { + if (relayName.length <= maxLength) return relayName; + const truncated = relayName.slice(0, maxLength).replace(/[\s._-]+$/g, "").trim(); + return truncated === "" ? relayName.slice(0, maxLength).trim() : truncated; +} + function textFromAssistant(message: AssistantMessage): string { return message.content .filter((part) => part.type === "text")