Archived
refactor: generate session names via agent.streamFn instead of pi-ai compat
Rewrite sessionNameGenerator.ts to consume a StreamFn-shaped dependency (sourced from PiAgentSession.agent.streamFn, wired in leg B) instead of ModelRegistry plus a dynamic @earendil-works/pi-ai/compat import. streamFn resolves auth/headers/retry internally, so the explicit modelRegistry.getApiKeyAndHeaders(model) call and apiKey/headers stream options are no longer needed. Deletes now-dead compat-loading machinery: getPiAiProviderRegistryModule, loadPiAiProviderRegistryModule, importOptionalPiAiModule, isModuleUnavailableError, hasGetApiProvider, PI_AI_COMPAT_MODULE, ModuleImporter, SessionNameApiProvider, PiAiProviderRegistryModule, and the module-level provider registry cache. Updates the maybeGenerateSessionName call site in piSessionService.ts to pass session.agent.streamFn instead of this.modelRegistry, and updates both test files: sessionNameGenerator.test.ts gains coverage for the new streamFn-driven generateShortSessionName signature (success and error paths), and piSessionService.test.ts gains an end-to-end test proving a first prompt generates a session name through the wired agent.streamFn fake. sessionNameGenerator.ts no longer imports @earendil-works/pi-ai/compat or references getApiProvider anywhere.
This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
import { createAssistantMessageEventStream, type AssistantMessage } from "@earendil-works/pi-ai";
|
||||||
|
import type { StreamFn } from "@earendil-works/pi-agent-core";
|
||||||
import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
|
import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import type { GlobalSessionEvent, SessionUiEvent } from "../../shared/apiTypes.js";
|
import type { GlobalSessionEvent, SessionUiEvent } from "../../shared/apiTypes.js";
|
||||||
@@ -938,6 +940,42 @@ describe("PiSessionService", () => {
|
|||||||
await service.dispose();
|
await service.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("generates a session name for the first prompt via the session's agent.streamFn", async () => {
|
||||||
|
const model = testModel();
|
||||||
|
const streamCalls: unknown[] = [];
|
||||||
|
const streamFn: StreamFn = (streamModel, context, options) => {
|
||||||
|
streamCalls.push({ streamModel, context, options });
|
||||||
|
const stream = createAssistantMessageEventStream();
|
||||||
|
const message: AssistantMessage = {
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "text", text: "Fix login bug" }],
|
||||||
|
api: "anthropic-messages",
|
||||||
|
provider: "anthropic",
|
||||||
|
model: model.id,
|
||||||
|
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
|
||||||
|
stopReason: "stop",
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
stream.push({ type: "done", reason: "stop", message });
|
||||||
|
stream.end(message);
|
||||||
|
return stream;
|
||||||
|
};
|
||||||
|
const hub = new CapturingSessionEventHub();
|
||||||
|
const fake = fakeRuntime("name-session", { model, agent: { streamFn } });
|
||||||
|
const service = new PiSessionService(hub, {
|
||||||
|
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||||
|
sessionManager: sessionGateway([sessionRecord("name-session")]),
|
||||||
|
heartbeatIntervalMs: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.prompt(sessionRef("name-session"), "Please fix the login bug");
|
||||||
|
await vi.waitFor(() => { expect(fake.session.sessionName).toBe("Fix login bug"); });
|
||||||
|
|
||||||
|
expect(streamCalls).toHaveLength(1);
|
||||||
|
expect(hub.sessionEvents.some(({ event }) => event.type === "session.name" && event.name === "Fix login bug")).toBe(true);
|
||||||
|
await service.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
it("includes queued message details in session status", async () => {
|
it("includes queued message details in session status", async () => {
|
||||||
const fake = fakeRuntime("status-session", {
|
const fake = fakeRuntime("status-session", {
|
||||||
messages: [{ role: "user", content: "hello" }, { role: "assistant", content: "hi" }],
|
messages: [{ role: "user", content: "hello" }, { role: "assistant", content: "hi" }],
|
||||||
|
|||||||
@@ -1707,7 +1707,7 @@ export class PiSessionService {
|
|||||||
const model = session.model;
|
const model = session.model;
|
||||||
if (model === undefined) return;
|
if (model === undefined) return;
|
||||||
|
|
||||||
void generateShortSessionName(this.modelRegistry, model, firstMessage).then((name) => {
|
void generateShortSessionName(session.agent.streamFn, model, firstMessage).then((name) => {
|
||||||
this.applyGeneratedSessionName(session, name ?? fallbackSessionName(firstMessage));
|
this.applyGeneratedSessionName(session, name ?? fallbackSessionName(firstMessage));
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
this.applyGeneratedSessionName(session, fallbackSessionName(firstMessage));
|
this.applyGeneratedSessionName(session, fallbackSessionName(firstMessage));
|
||||||
|
|||||||
@@ -1,7 +1,70 @@
|
|||||||
|
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 { describe, expect, it } from "vitest";
|
||||||
import { cleanSessionName, fallbackSessionName } from "./sessionNameGenerator.js";
|
import { cleanSessionName, 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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeAssistantMessage(overrides: Partial<AssistantMessage> = {}): AssistantMessage {
|
||||||
|
return {
|
||||||
|
role: "assistant",
|
||||||
|
content: [],
|
||||||
|
api: "anthropic-messages",
|
||||||
|
provider: "anthropic",
|
||||||
|
model: "fake-model",
|
||||||
|
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
|
||||||
|
stopReason: "stop",
|
||||||
|
timestamp: Date.now(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function streamThatCompletes(text: string): StreamFn {
|
||||||
|
return () => {
|
||||||
|
const stream = createAssistantMessageEventStream();
|
||||||
|
const message = fakeAssistantMessage({ content: [{ type: "text", text }] });
|
||||||
|
stream.push({ type: "done", reason: "stop", message });
|
||||||
|
stream.end(message);
|
||||||
|
return stream;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function streamThatErrors(): StreamFn {
|
||||||
|
return () => {
|
||||||
|
const stream = createAssistantMessageEventStream();
|
||||||
|
const message = fakeAssistantMessage({ stopReason: "error", errorMessage: "boom" });
|
||||||
|
stream.push({ type: "error", reason: "error", error: message });
|
||||||
|
stream.end(message);
|
||||||
|
return stream;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe("sessionNameGenerator", () => {
|
describe("sessionNameGenerator", () => {
|
||||||
|
it("generates a session name by calling the injected streamFn", async () => {
|
||||||
|
const calls: unknown[] = [];
|
||||||
|
const stream = streamThatCompletes('Title: "Fix the bug"');
|
||||||
|
const streamFn: StreamFn = (model, context, options) => {
|
||||||
|
calls.push({ model, context, options });
|
||||||
|
return stream(model, context, options);
|
||||||
|
};
|
||||||
|
|
||||||
|
const name = await generateShortSessionName(streamFn, fakeModel(), "Please fix the login bug");
|
||||||
|
|
||||||
|
expect(name).toBe("Fix the bug");
|
||||||
|
expect(calls).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined when the stream reports an error", async () => {
|
||||||
|
const streamFn = streamThatErrors();
|
||||||
|
|
||||||
|
const name = await generateShortSessionName(streamFn, fakeModel(), "Please fix the login bug");
|
||||||
|
|
||||||
|
expect(name).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it("cleans model-generated titles", () => {
|
it("cleans model-generated titles", () => {
|
||||||
expect(cleanSessionName('Title: "Fix Session Naming."\nextra')).toBe("Fix Session Naming");
|
expect(cleanSessionName('Title: "Fix Session Naming."\nextra')).toBe("Fix Session Naming");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,33 +1,13 @@
|
|||||||
import type { Api, AssistantMessage, AssistantMessageEventStream, Context, Model, SimpleStreamOptions } from "@earendil-works/pi-ai";
|
import type { Api, AssistantMessage, Model } from "@earendil-works/pi-ai";
|
||||||
import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
|
import type { StreamFn } from "@earendil-works/pi-agent-core";
|
||||||
|
|
||||||
const SESSION_NAME_TIMEOUT_MS = 10_000;
|
const SESSION_NAME_TIMEOUT_MS = 10_000;
|
||||||
const SESSION_NAME_MAX_INPUT_CHARS = 4_000;
|
const SESSION_NAME_MAX_INPUT_CHARS = 4_000;
|
||||||
const SESSION_NAME_MAX_LENGTH = 60;
|
const SESSION_NAME_MAX_LENGTH = 60;
|
||||||
const FALLBACK_SESSION_NAME_MAX_WORDS = 6;
|
const FALLBACK_SESSION_NAME_MAX_WORDS = 6;
|
||||||
const PI_AI_COMPAT_MODULE = ["@earendil-works/pi-ai", "compat"].join("/");
|
|
||||||
|
|
||||||
interface SessionNameApiProvider {
|
export async function generateShortSessionName<TApi extends Api>(streamFn: StreamFn, model: Model<TApi>, firstMessage: string): Promise<string | undefined> {
|
||||||
streamSimple(model: Model<Api>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream;
|
const stream = await streamFn(
|
||||||
}
|
|
||||||
|
|
||||||
interface PiAiProviderRegistryModule {
|
|
||||||
getApiProvider?: (api: Api) => SessionNameApiProvider | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
type ModuleImporter = (specifier: string) => Promise<unknown>;
|
|
||||||
|
|
||||||
let piAiProviderRegistryModulePromise: Promise<PiAiProviderRegistryModule> | undefined;
|
|
||||||
|
|
||||||
export async function generateShortSessionName<TApi extends Api>(modelRegistry: ModelRegistry, model: Model<TApi>, firstMessage: string): Promise<string | undefined> {
|
|
||||||
const providerRegistry = await getPiAiProviderRegistryModule();
|
|
||||||
const provider = providerRegistry.getApiProvider?.(model.api);
|
|
||||||
if (provider === undefined) return undefined;
|
|
||||||
|
|
||||||
const auth = await modelRegistry.getApiKeyAndHeaders(model);
|
|
||||||
if (!auth.ok) return undefined;
|
|
||||||
|
|
||||||
const stream = provider.streamSimple(
|
|
||||||
model,
|
model,
|
||||||
{
|
{
|
||||||
systemPrompt: "Generate a concise title for a coding-agent chat session. Return only the title, with no quotes or punctuation wrapper.",
|
systemPrompt: "Generate a concise title for a coding-agent chat session. Return only the title, with no quotes or punctuation wrapper.",
|
||||||
@@ -41,8 +21,6 @@ export async function generateShortSessionName<TApi extends Api>(modelRegistry:
|
|||||||
maxTokens: 24,
|
maxTokens: 24,
|
||||||
reasoning: "minimal",
|
reasoning: "minimal",
|
||||||
signal: AbortSignal.timeout(SESSION_NAME_TIMEOUT_MS),
|
signal: AbortSignal.timeout(SESSION_NAME_TIMEOUT_MS),
|
||||||
...(auth.apiKey === undefined ? {} : { apiKey: auth.apiKey }),
|
|
||||||
...(auth.headers === undefined ? {} : { headers: auth.headers }),
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -81,42 +59,6 @@ export function cleanSessionName(value: string): string | undefined {
|
|||||||
return title === "" ? undefined : title;
|
return title === "" ? undefined : title;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getPiAiProviderRegistryModule(importer: ModuleImporter = (specifier) => import(specifier)): Promise<PiAiProviderRegistryModule> {
|
|
||||||
piAiProviderRegistryModulePromise ??= loadPiAiProviderRegistryModule(importer);
|
|
||||||
return piAiProviderRegistryModulePromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadPiAiProviderRegistryModule(importer: ModuleImporter): Promise<PiAiProviderRegistryModule> {
|
|
||||||
const compatModule = await importOptionalPiAiModule(PI_AI_COMPAT_MODULE, importer);
|
|
||||||
if (hasGetApiProvider(compatModule)) return compatModule;
|
|
||||||
|
|
||||||
const rootModule = await importer("@earendil-works/pi-ai");
|
|
||||||
if (hasGetApiProvider(rootModule)) return rootModule;
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function importOptionalPiAiModule(specifier: string, importer: ModuleImporter): Promise<unknown> {
|
|
||||||
try {
|
|
||||||
return await importer(specifier);
|
|
||||||
} catch (error) {
|
|
||||||
if (isModuleUnavailableError(error)) return undefined;
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasGetApiProvider(moduleValue: unknown): moduleValue is PiAiProviderRegistryModule {
|
|
||||||
return typeof moduleValue === "object"
|
|
||||||
&& moduleValue !== null
|
|
||||||
&& "getApiProvider" in moduleValue
|
|
||||||
&& typeof moduleValue.getApiProvider === "function";
|
|
||||||
}
|
|
||||||
|
|
||||||
function isModuleUnavailableError(error: unknown): boolean {
|
|
||||||
if (!(error instanceof Error)) return false;
|
|
||||||
const code = "code" in error ? error.code : undefined;
|
|
||||||
return code === "ERR_MODULE_NOT_FOUND" || code === "ERR_PACKAGE_PATH_NOT_EXPORTED";
|
|
||||||
}
|
|
||||||
|
|
||||||
function textFromAssistant(message: AssistantMessage): string {
|
function textFromAssistant(message: AssistantMessage): string {
|
||||||
return message.content
|
return message.content
|
||||||
.filter((part) => part.type === "text")
|
.filter((part) => part.type === "text")
|
||||||
|
|||||||
Reference in New Issue
Block a user