fix(sessions): name relay handoffs deterministically

This commit is contained in:
Federico Jaramillo Martinez
2026-07-03 10:51:45 +02:00
parent d2b5520785
commit 10efb7f221
4 changed files with 61 additions and 2 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Name Relay handoff sessions deterministically from their relay name and leg number.
+8 -1
View File
@@ -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;
@@ -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<Api> {
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");
@@ -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<TApi extends Api>(streamFn: StreamFn, model: Model<TApi>, firstMessage: string): Promise<string | undefined> {
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")