Skip to content

Commit 238f19a

Browse files
committed
Fixed Pi provider model picker corruption, labels, and added thread title auto-generation
Model picker & display fixes: - Added "pi" to toLegacyProvider() allowlist (mappers.store.ts) — prevents session provider from being silently coerced to "codex" after first turn - Added formatSlugAsDisplayName() helper in ProviderModelPicker.tsx to convert raw slugs like "gemini-3-flash-preview" into friendly display names - Fixed getDefaultModelSelection() and composer hook to preserve subProviderID so model picker stays on the selected Pi model after sending - Added PI_PROVIDER_DISPLAY_NAMES and getPiProviderDisplayName() helpers in PiProvider.utils.ts for consistent provider group headers Thread title auto-generation: - Added generatePiThreadTitleNative() that spawns a temporary Pi RPC process, writes the title prompt via write() (avoids early RPC ack resolution), collects streamed text deltas until turn_end/agent_end, and stops the process via Effect.ensuring. Single Effect.timeoutOption timeout with no inner setTimeout race; rejects on empty response - Routed Pi provider in RoutingTextGeneration.ts to native title generation instead of Claude CLI fallback; removed unsafe type casts - Fixed maybeGenerateThreadTitleForFirstTurn gate: now falls back to thread.modelSelection when event.payload.modelSelection is absent (Pi turn-start events don't carry it), ensuring Pi threads always trigger title generation - Handled setTitle extension UI request in PiAdapter.stream.handlers.ts: emits thread.metadata.updated wrapped in catchCause so schema failures don't disrupt session event flow
1 parent edd2ca3 commit 238f19a

58 files changed

Lines changed: 4680 additions & 624 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/server/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"@effect/platform-node": "catalog:",
3030
"@effect/sql-sqlite-bun": "catalog:",
3131
"@github/copilot-sdk": "0.2.1",
32+
"@mariozechner/pi-coding-agent": "^0.67.6",
3233
"@opencode-ai/sdk": "^1.3.13",
3334
"@pierre/diffs": "^1.1.0-beta.16",
3435
"effect": "catalog:",

apps/server/src/git/Layers/ProviderNativeThreadTitleGeneration.ts

Lines changed: 170 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,20 +8,25 @@ import {
88
TextGenerationError,
99
type CopilotModelSelection,
1010
type OpencodeModelSelection,
11+
type PiModelSelection,
1112
} from "@bigcode/contracts";
1213
import { Effect, Option } from "effect";
1314

15+
import { createPiRpcProcess } from "../../provider/Layers/PiRpcProcess.ts";
16+
import type { PiRpcStdoutEvent } from "../../provider/Layers/PiRpcProcess.ts";
17+
import type { ServerSettingsShape } from "../../ws/serverSettings.ts";
18+
1419
import type { ThreadTitleGenerationInput } from "../Services/TextGeneration.ts";
1520
import { buildThreadTitlePrompt } from "../Prompts.ts";
1621
import { limitSection, sanitizeThreadTitle } from "../Utils.ts";
1722
import { makeNodeWrapperCliPath } from "../../provider/Layers/CopilotAdapter.types.ts";
1823
import { resolveProviderIDForModel } from "../../provider/Layers/OpencodeAdapter.session.helpers.ts";
1924
import { withOpencodeDirectory } from "../../provider/Layers/OpencodeAdapter.stream.ts";
20-
import type { ServerSettingsShape } from "../../ws/serverSettings.ts";
2125
import type { OpencodeServerManagerShape } from "../../provider/Services/OpencodeServerManager.ts";
2226

2327
const COPILOT_TIMEOUT_MS = 60_000;
2428
const OPENCODE_TIMEOUT_MS = 60_000;
29+
const PI_TIMEOUT_MS = 60_000;
2530

2631
type CopilotThreadTitleGenerationInput = Omit<ThreadTitleGenerationInput, "modelSelection"> & {
2732
readonly modelSelection: CopilotModelSelection;
@@ -31,6 +36,10 @@ type OpencodeThreadTitleGenerationInput = Omit<ThreadTitleGenerationInput, "mode
3136
readonly modelSelection: OpencodeModelSelection;
3237
};
3338

39+
type PiThreadTitleGenerationInput = Omit<ThreadTitleGenerationInput, "modelSelection"> & {
40+
readonly modelSelection: PiModelSelection;
41+
};
42+
3443
export interface NativeThreadTitleGenerationDeps {
3544
readonly serverSettingsService: ServerSettingsShape;
3645
readonly opencodeServerManager: OpencodeServerManagerShape;
@@ -321,3 +330,163 @@ export const generateOpencodeThreadTitleNative = (
321330
serverHandle.release();
322331
}
323332
});
333+
334+
const PI_TITLE_PROMPT_PREFIX = [
335+
"Write a concise thread title for a coding conversation.",
336+
"Return plain text only — no JSON, no quotes, no prefixes, no trailing punctuation.",
337+
"Keep it short and specific (3-8 words).",
338+
"",
339+
"User message:",
340+
].join("\n");
341+
342+
/**
343+
* Generates a thread title using the Pi RPC process directly.
344+
* Spawns a temporary Pi process, sends the title-generation prompt,
345+
* collects streamed text, then stops the process.
346+
*/
347+
export const generatePiThreadTitleNative = (
348+
deps: NativeThreadTitleGenerationDeps,
349+
input: PiThreadTitleGenerationInput,
350+
) =>
351+
Effect.gen(function* () {
352+
const piSettings = yield* deps.serverSettingsService.getSettings.pipe(
353+
Effect.map((settings) => settings.providers.pi),
354+
Effect.mapError(
355+
() =>
356+
new TextGenerationError({
357+
operation: "generateThreadTitle",
358+
detail: "Failed to read Pi settings.",
359+
}),
360+
),
361+
);
362+
363+
const prompt = [
364+
PI_TITLE_PROMPT_PREFIX,
365+
limitSection(input.message, 8_000),
366+
...(input.attachments && input.attachments.length > 0
367+
? [
368+
"",
369+
"Attachment metadata:",
370+
limitSection(
371+
input.attachments
372+
.map((a) => `- ${a.name} (${a.mimeType}, ${a.sizeBytes} bytes)`)
373+
.join("\n"),
374+
4_000,
375+
),
376+
]
377+
: []),
378+
].join("\n");
379+
380+
const rpcProcess = yield* Effect.tryPromise({
381+
try: () =>
382+
createPiRpcProcess({
383+
binaryPath: piSettings.binaryPath,
384+
...(input.cwd ? { cwd: input.cwd } : {}),
385+
env: process.env,
386+
}),
387+
catch: (cause) =>
388+
new TextGenerationError({
389+
operation: "generateThreadTitle",
390+
detail:
391+
cause instanceof Error
392+
? `Failed to start Pi process for thread title generation: ${cause.message}`
393+
: "Failed to start Pi process for thread title generation.",
394+
cause,
395+
}),
396+
});
397+
398+
const stopProcess = Effect.promise(() => rpcProcess.stop().catch(() => undefined));
399+
400+
const generateTitle = Effect.tryPromise({
401+
try: () =>
402+
new Promise<string>((resolve, reject) => {
403+
let collectedText = "";
404+
let settled = false;
405+
406+
const unsubscribe = rpcProcess.subscribe((message) => {
407+
if (!("type" in message)) return;
408+
const event = message as PiRpcStdoutEvent;
409+
410+
if (
411+
event.type === "message_update" &&
412+
"assistantMessageEvent" in event &&
413+
event.assistantMessageEvent?.type === "text_delta"
414+
) {
415+
collectedText += event.assistantMessageEvent.delta;
416+
} else if (event.type === "message_end") {
417+
// Fallback: extract text from final message if streaming deltas weren't received
418+
const msg = (event as { type: "message_end"; message: Record<string, unknown> })
419+
.message;
420+
if (collectedText.length === 0) {
421+
const content = msg.content;
422+
if (typeof content === "string" && content.trim().length > 0) {
423+
collectedText = content;
424+
} else if (Array.isArray(content)) {
425+
for (const part of content) {
426+
if (
427+
part &&
428+
typeof part === "object" &&
429+
"type" in part &&
430+
part.type === "text" &&
431+
"text" in part &&
432+
typeof part.text === "string"
433+
) {
434+
collectedText += part.text;
435+
}
436+
}
437+
}
438+
}
439+
} else if (event.type === "turn_end" || event.type === "agent_end") {
440+
if (!settled) {
441+
settled = true;
442+
unsubscribe();
443+
if (collectedText.trim().length === 0) {
444+
reject(new Error("Pi thread title generation produced an empty response."));
445+
} else {
446+
resolve(collectedText);
447+
}
448+
}
449+
}
450+
});
451+
452+
// Use write() instead of request() so the promise doesn't resolve early
453+
// on the RPC acknowledgment — we wait for turn_end above instead.
454+
rpcProcess.write({ type: "prompt", message: prompt }).catch((err: unknown) => {
455+
if (!settled) {
456+
settled = true;
457+
unsubscribe();
458+
reject(err instanceof Error ? err : new Error(String(err)));
459+
}
460+
});
461+
}),
462+
catch: (cause) =>
463+
new TextGenerationError({
464+
operation: "generateThreadTitle",
465+
detail:
466+
cause instanceof Error
467+
? `Pi thread title generation failed: ${cause.message}`
468+
: "Pi thread title generation failed.",
469+
cause,
470+
}),
471+
}).pipe(
472+
Effect.timeoutOption(PI_TIMEOUT_MS),
473+
Effect.flatMap(
474+
Option.match({
475+
onNone: () =>
476+
Effect.fail(
477+
new TextGenerationError({
478+
operation: "generateThreadTitle",
479+
detail: "Pi thread title generation timed out.",
480+
}),
481+
),
482+
onSome: (value) => Effect.succeed(value),
483+
}),
484+
),
485+
);
486+
487+
const title = yield* generateTitle.pipe(Effect.ensuring(stopProcess));
488+
489+
return {
490+
title: sanitizeThreadTitle(title.trim()),
491+
};
492+
});

apps/server/src/git/Layers/RoutingTextGeneration.ts

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,14 @@ import {
1818
import {
1919
TextGeneration,
2020
type TextGenerationProvider,
21-
type ThreadTitleGenerationInput,
2221
type TextGenerationShape,
2322
} from "../Services/TextGeneration.ts";
2423
import { CodexTextGenerationLive } from "./CodexTextGeneration.ts";
2524
import { ClaudeTextGenerationLive } from "./ClaudeTextGeneration.ts";
2625
import {
2726
generateCopilotThreadTitleNative,
2827
generateOpencodeThreadTitleNative,
28+
generatePiThreadTitleNative,
2929
} from "./ProviderNativeThreadTitleGeneration.ts";
3030
import { ServerSettingsService } from "../../ws/serverSettings.ts";
3131
import { OpencodeServerManager } from "../../provider/Services/OpencodeServerManager.ts";
@@ -49,6 +49,7 @@ export function normalizeTextGenerationModelSelection(
4949
case "claudeAgent":
5050
case "codex":
5151
return modelSelection;
52+
case "pi":
5253
case "opencode":
5354
return {
5455
provider: "claudeAgent",
@@ -109,16 +110,31 @@ const makeRoutingTextGeneration = Effect.gen(function* () {
109110
case "codex":
110111
case "claudeAgent":
111112
return route(input.modelSelection.provider).generateThreadTitle(input);
113+
case "pi":
114+
return generatePiThreadTitleNative(
115+
{
116+
serverSettingsService,
117+
opencodeServerManager,
118+
},
119+
{
120+
cwd: input.cwd,
121+
message: input.message,
122+
...(input.attachments !== undefined ? { attachments: input.attachments } : {}),
123+
modelSelection: input.modelSelection,
124+
},
125+
);
112126
case "copilot":
113127
return generateCopilotThreadTitleNative(
114128
{
115129
serverSettingsService,
116130
opencodeServerManager,
117131
},
118132
{
119-
...(input as ThreadTitleGenerationInput),
133+
cwd: input.cwd,
134+
message: input.message,
135+
...(input.attachments !== undefined ? { attachments: input.attachments } : {}),
120136
modelSelection: input.modelSelection,
121-
} as ThreadTitleGenerationInput & { modelSelection: { provider: "copilot" } },
137+
},
122138
);
123139
case "opencode":
124140
return generateOpencodeThreadTitleNative(
@@ -127,9 +143,11 @@ const makeRoutingTextGeneration = Effect.gen(function* () {
127143
opencodeServerManager,
128144
},
129145
{
130-
...(input as ThreadTitleGenerationInput),
146+
cwd: input.cwd,
147+
message: input.message,
148+
...(input.attachments !== undefined ? { attachments: input.attachments } : {}),
131149
modelSelection: input.modelSelection,
132-
} as ThreadTitleGenerationInput & { modelSelection: { provider: "opencode" } },
150+
},
133151
);
134152
}
135153
},

apps/server/src/orchestration/Layers/ProviderCommandReactorHandlers.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -217,14 +217,14 @@ export const makeProviderCommandHandlers = Effect.gen(function* () {
217217
...generationInput,
218218
}).pipe(Effect.forkScoped);
219219

220-
if (
221-
event.payload.modelSelection &&
222-
canReplaceThreadTitle(thread.title, event.payload.titleSeed)
223-
) {
220+
if (canReplaceThreadTitle(thread.title, event.payload.titleSeed)) {
221+
// Fall back to the thread's own modelSelection when the turn-start event doesn't
222+
// carry one (e.g. Pi, where the thread is already bound to a provider).
223+
const titleModelSelection = event.payload.modelSelection ?? thread.modelSelection;
224224
yield* maybeGenerateThreadTitleForFirstTurn(sessionOpServices)({
225225
threadId: event.payload.threadId,
226226
cwd: generationCwd,
227-
modelSelection: event.payload.modelSelection,
227+
modelSelection: titleModelSelection,
228228
...generationInput,
229229
}).pipe(Effect.forkScoped);
230230
}

0 commit comments

Comments
 (0)