mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
877b0c78ac
* refactor(ai): extract BaseSession and buildEffectivePrompt Pull shared session lifecycle (~80 lines duplicated across Claude and Codex providers) into BaseSession: query guard, abort, ID resolution, generation counter. Extract first-query prompt prepending into buildEffectivePrompt() in context.ts. Both providers now extend BaseSession. Behavior-preserving — all 54 existing tests pass unchanged. For provenance purposes, this commit was AI assisted. * feat(ai): add Pi provider via RPC subprocess Spawns `pi --mode rpc` and communicates via JSONL over stdio. No Pi SDK bundled — user must have the pi CLI installed. Provider auto-discovers available models at startup via get_available_models. Includes: PiProcess (JSONL client), PiSDKProvider, PiSDKSession extending BaseSession, mapPiEvent for streaming, Pi icon in provider UI, and 12 new tests (9 event mapping + 3 buildEffectivePrompt). For provenance purposes, this commit was AI assisted. * fix(ai): harden Pi provider error paths - Surface startup errors when Pi process dies (bad config, missing API keys) instead of hanging the chat indefinitely - Map tool-call errors as non-terminal tool_result instead of AIErrorMessage, so Pi can continue reasoning after a failed tool call - Fix subprocess leak in fetchModels() by moving proc.kill() to finally block For provenance purposes, this commit was AI assisted. * fix(ai): detect Pi prompt rejections and process crashes - Use sendAndWait for prompt command so RPC-level rejections (expired credentials, invalid session) surface as errors instead of hanging - Emit process_exited instead of synthetic agent_end when Pi subprocess dies, so crashes show as errors instead of silent empty successes For provenance purposes, this commit was AI assisted. * docs(ai): add Pi to AI features guide, code review docs, and installation For provenance purposes, this commit was AI assisted. * style(ai): apply biome formatting to new files For provenance purposes, this commit was AI assisted.
95 lines
2.8 KiB
TypeScript
95 lines
2.8 KiB
TypeScript
/**
|
|
* Shared session base class — extracts the common lifecycle, abort, and
|
|
* ID-resolution logic that every AIProvider session needs.
|
|
*
|
|
* Concrete providers extend this and implement query().
|
|
*/
|
|
|
|
import type { AIMessage, AISession } from "./types.ts";
|
|
|
|
export abstract class BaseSession implements AISession {
|
|
readonly parentSessionId: string | null;
|
|
onIdResolved?: (oldId: string, newId: string) => void;
|
|
|
|
protected _placeholderId: string;
|
|
protected _resolvedId: string | null = null;
|
|
protected _isActive = false;
|
|
protected _currentAbort: AbortController | null = null;
|
|
protected _queryGen = 0;
|
|
protected _firstQuerySent = false;
|
|
|
|
constructor(opts: { parentSessionId: string | null; initialId?: string }) {
|
|
this.parentSessionId = opts.parentSessionId;
|
|
this._placeholderId = opts.initialId ?? crypto.randomUUID();
|
|
}
|
|
|
|
get id(): string {
|
|
return this._resolvedId ?? this._placeholderId;
|
|
}
|
|
|
|
get isActive(): boolean {
|
|
return this._isActive;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Query lifecycle helpers — call from concrete query() implementations
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Error message returned when a query is already active. */
|
|
static readonly BUSY_ERROR: AIMessage = {
|
|
type: "error",
|
|
error:
|
|
"A query is already in progress. Abort the current query before sending a new one.",
|
|
code: "session_busy",
|
|
};
|
|
|
|
/**
|
|
* Call at the start of query(). Returns the generation number and abort
|
|
* signal, or null if the session is busy.
|
|
*/
|
|
protected startQuery(): { gen: number; signal: AbortSignal } | null {
|
|
if (this._isActive) return null;
|
|
|
|
const gen = ++this._queryGen;
|
|
this._isActive = true;
|
|
this._currentAbort = new AbortController();
|
|
return { gen, signal: this._currentAbort.signal };
|
|
}
|
|
|
|
/**
|
|
* Call in the finally block of query(). Only clears state if the
|
|
* generation matches (prevents a stale finally from clobbering a newer query).
|
|
*/
|
|
protected endQuery(gen: number): void {
|
|
if (this._queryGen === gen) {
|
|
this._isActive = false;
|
|
this._currentAbort = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Call when the provider resolves the real session ID from the backend.
|
|
* Fires the onIdResolved callback so the SessionManager can remap its key.
|
|
*/
|
|
protected resolveId(newId: string): void {
|
|
if (this._resolvedId) return; // Already resolved
|
|
const oldId = this._placeholderId;
|
|
this._resolvedId = newId;
|
|
this.onIdResolved?.(oldId, newId);
|
|
}
|
|
|
|
/**
|
|
* Abort the current in-flight query. Subclasses should call super.abort()
|
|
* after any provider-specific cleanup.
|
|
*/
|
|
abort(): void {
|
|
if (this._currentAbort) {
|
|
this._currentAbort.abort();
|
|
this._isActive = false;
|
|
this._currentAbort = null;
|
|
}
|
|
}
|
|
|
|
abstract query(prompt: string): AsyncIterable<AIMessage>;
|
|
}
|