arisa 4.3.0 → 4.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arisa",
3
- "version": "4.3.0",
3
+ "version": "4.3.2",
4
4
  "description": "Telegram + Pi Agent modular assistant",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -3,6 +3,7 @@ import { readFile, stat, unlink } from "node:fs/promises";
3
3
  import { createAgentSession, DefaultResourceLoader, SessionManager, defineTool } from "@earendil-works/pi-coding-agent";
4
4
  import { Type } from "@sinclair/typebox";
5
5
  import { createPiRuntime, hasProviderAuth } from "./pi-runtime.js";
6
+ import { resolveChatModelSelection } from "./model-selection.js";
6
7
  import { appendArisaAgentsFile, arisaAgentsFile, arisaInstallDir, buildAgentRuntimeContext } from "./runtime-context.js";
7
8
  import { withTimeout } from "./prompt-timeout.js";
8
9
  import { buildPiToolPolicy, getCoreCodingTools } from "./core-tools.js";
@@ -126,9 +127,9 @@ export class AgentManager {
126
127
  this.sessions.delete(String(chatId));
127
128
  }
128
129
 
129
- createSessionManager(chatId, workspaceDir = arisaInstallDir) {
130
+ createSessionManager(chatId, workspaceDir = arisaInstallDir, sessionRevision = 0) {
130
131
  const sessionKey = String(chatId);
131
- const sessionDir = getChatPiSessionsDir(sessionKey);
132
+ const sessionDir = getChatPiSessionsDir(sessionKey, sessionRevision);
132
133
  if (this.pendingNewSessions.has(sessionKey)) {
133
134
  this.logger?.log("agent", `starting new persisted session for chat ${sessionKey}`);
134
135
  return { sessionManager: SessionManager.create(workspaceDir, sessionDir), isNewSession: true };
@@ -165,14 +166,16 @@ export class AgentManager {
165
166
 
166
167
  async getSessionContext(chatId, telegram) {
167
168
  const sessionKey = String(chatId);
168
- const effectiveModelId = this.config.pi.model;
169
+ const modelSelection = resolveChatModelSelection(this.config, sessionKey);
170
+ const effectiveModelId = modelSelection.model;
171
+ const effectiveModelKey = `${modelSelection.provider}/${effectiveModelId}@${modelSelection.sessionRevision}`;
169
172
  if (this.sessions.has(sessionKey)) {
170
173
  const existing = this.sessions.get(sessionKey);
171
- if (existing?.modelId === effectiveModelId) {
174
+ if (existing?.modelKey === effectiveModelKey) {
172
175
  this.logger?.log("agent", `reusing session for chat ${sessionKey}`);
173
176
  return existing;
174
177
  }
175
- this.logger?.log("agent", `model changed for chat ${sessionKey}: ${existing?.modelId || "unknown"} -> ${effectiveModelId}; recreating session`);
178
+ this.logger?.log("agent", `model changed for chat ${sessionKey}: ${existing?.modelKey || "unknown"} -> ${effectiveModelKey}; recreating session`);
176
179
  this.sessions.delete(sessionKey);
177
180
  this.pendingNewSessions.add(sessionKey);
178
181
  }
@@ -192,7 +195,11 @@ export class AgentManager {
192
195
  customToolNames: [...arisaToolNames, "system_shell"]
193
196
  });
194
197
  await assertDirectory(policy.workspaceDir, "pi.workspaceDir");
195
- const { sessionManager, isNewSession } = this.createSessionManager(sessionKey, policy.workspaceDir);
198
+ const { sessionManager, isNewSession } = this.createSessionManager(
199
+ sessionKey,
200
+ policy.workspaceDir,
201
+ modelSelection.sessionRevision
202
+ );
196
203
  const hasExistingSession = sessionManager.buildSessionContext().messages.length > 0;
197
204
  this.logger?.log("agent", `${hasExistingSession ? "resuming" : "creating"} session for chat ${sessionKey} with model ${effectiveModelId}`);
198
205
  const customTools = [
@@ -224,7 +231,7 @@ export class AgentManager {
224
231
  })}`);
225
232
  }
226
233
 
227
- const ctx = { session, modelId: effectiveModelId };
234
+ const ctx = { session, modelId: effectiveModelId, modelKey: effectiveModelKey };
228
235
  this.sessions.set(sessionKey, ctx);
229
236
  if (isNewSession) {
230
237
  this.pendingNewSessions.delete(sessionKey);
@@ -0,0 +1,36 @@
1
+ function chatKey(chatId) {
2
+ return String(chatId);
3
+ }
4
+
5
+ export function resolveChatModelSelection(config, chatId) {
6
+ const selection = config.pi.chatModels?.[chatKey(chatId)];
7
+ if (!selection || selection.provider !== config.pi.provider) {
8
+ return {
9
+ provider: config.pi.provider,
10
+ model: config.pi.model,
11
+ sessionRevision: 0
12
+ };
13
+ }
14
+ if (!Number.isSafeInteger(selection.sessionRevision) || selection.sessionRevision <= 0) {
15
+ throw new Error(`Invalid model session revision for chat ${chatId}`);
16
+ }
17
+ return selection;
18
+ }
19
+
20
+ export function resolveChatModel(config, chatId) {
21
+ return resolveChatModelSelection(config, chatId).model;
22
+ }
23
+
24
+ export function selectChatModel(config, chatId, model) {
25
+ if (model.provider !== config.pi.provider) {
26
+ throw new Error(`Cannot select model from provider ${model.provider}; active provider is ${config.pi.provider}`);
27
+ }
28
+ config.pi.chatModels ||= {};
29
+ const key = chatKey(chatId);
30
+ const sessionRevision = (config.pi.chatModels[key]?.sessionRevision || 0) + 1;
31
+ config.pi.chatModels[key] = {
32
+ provider: model.provider,
33
+ model: model.id,
34
+ sessionRevision
35
+ };
36
+ }
@@ -53,6 +53,14 @@ export function listProviderModels(provider, runtime = createPiRuntime()) {
53
53
  .sort((a, b) => compareText(a.name || a.id, b.name || b.id));
54
54
  }
55
55
 
56
+ export function formatPiModelOption(model) {
57
+ const capabilities = [
58
+ model.reasoning ? "reasoning" : null,
59
+ model.input?.includes("image") ? "image" : null
60
+ ].filter(Boolean).join(", ");
61
+ return capabilities ? `${model.id} [${capabilities}]` : model.id;
62
+ }
63
+
56
64
  export function findPiModel({ provider, model, apiKey } = {}) {
57
65
  const runtime = createPiRuntime({ provider, apiKey });
58
66
  return {
@@ -14,9 +14,17 @@ export const daemonConfigDefaults = Object.freeze({
14
14
  queuePollIntervalMs: 250
15
15
  });
16
16
 
17
+ export const telegramConfigDefaults = Object.freeze({
18
+ modelPickerPageSize: 8
19
+ });
20
+
17
21
  export function applyConfigDefaults(config) {
18
22
  return {
19
23
  ...config,
24
+ telegram: {
25
+ ...telegramConfigDefaults,
26
+ ...(config.telegram || {})
27
+ },
20
28
  daemons: {
21
29
  ...daemonConfigDefaults,
22
30
  ...(config.daemons || {})
@@ -337,11 +337,16 @@ async function listChatDaemonRecords() {
337
337
  const records = [];
338
338
  for (const chatEntry of chatEntries) {
339
339
  if (!chatEntry.isDirectory()) continue;
340
+ let scope;
341
+ try {
342
+ scope = normalizeDaemonScope({ type: "chat", chatId: chatEntry.name });
343
+ } catch {
344
+ continue;
345
+ }
340
346
  const toolsRoot = path.join(chatsDir, chatEntry.name, "state", "tools");
341
347
  const toolEntries = await readdir(toolsRoot, { withFileTypes: true }).catch(() => []);
342
348
  for (const toolEntry of toolEntries) {
343
349
  if (!toolEntry.isDirectory()) continue;
344
- const scope = { type: "chat", chatId: chatEntry.name };
345
350
  const meta = await readJson(daemonPaths({ toolName: toolEntry.name, scope }).metaFile, null);
346
351
  if (meta?.toolName && meta?.entryPath) records.push(meta);
347
352
  }
@@ -5,9 +5,10 @@ import { stdin as input, stdout as output } from "node:process";
5
5
  import { spawn } from "node:child_process";
6
6
  import { Bot } from "grammy";
7
7
  import { createPiOAuthLogin } from "../core/agent/pi-auth-login.js";
8
- import { createPiRuntime, hasProviderAuth, listPiProviders, listProviderModels, supportsProviderOAuth } from "../core/agent/pi-runtime.js";
9
- import { applyConfigDefaults } from "../core/config/config-defaults.js";
8
+ import { createPiRuntime, formatPiModelOption, hasProviderAuth, listPiProviders, listProviderModels, supportsProviderOAuth } from "../core/agent/pi-runtime.js";
9
+ import { applyConfigDefaults, telegramConfigDefaults } from "../core/config/config-defaults.js";
10
10
  import { buildDeviceCodeTelegramMessage } from "../transport/telegram/device-code-message.js";
11
+ import { buildPagedInlineKeyboard } from "../transport/telegram/paged-inline-keyboard.js";
11
12
  import { configFile, ensureArisaHome } from "./paths.js";
12
13
 
13
14
  const ARISA_BANNER = [
@@ -100,30 +101,6 @@ function parseYesNo(value, fallback = true) {
100
101
  return null;
101
102
  }
102
103
 
103
- function buildPagedInlineKeyboard(action, items, { page = 0, pageSize = 8 } = {}) {
104
- const pageCount = Math.max(1, Math.ceil(items.length / pageSize));
105
- const currentPage = Math.max(0, Math.min(pageCount - 1, page));
106
- const startIndex = currentPage * pageSize;
107
- const rows = items.slice(startIndex, startIndex + pageSize).map((item, index) => ([{
108
- text: item.text,
109
- callback_data: `${action}:${startIndex + index}`
110
- }]));
111
-
112
- if (pageCount > 1) {
113
- const navigation = [];
114
- if (currentPage > 0) {
115
- navigation.push({ text: "Previous", callback_data: `${action}-page:${currentPage - 1}` });
116
- }
117
- navigation.push({ text: `${currentPage + 1}/${pageCount}`, callback_data: "noop:page" });
118
- if (currentPage < pageCount - 1) {
119
- navigation.push({ text: "Next", callback_data: `${action}-page:${currentPage + 1}` });
120
- }
121
- rows.push(navigation);
122
- }
123
-
124
- return { inline_keyboard: rows };
125
- }
126
-
127
104
  function getIncomingChatMeta(ctx) {
128
105
  return {
129
106
  languageCode: ctx.from?.language_code || "",
@@ -138,11 +115,6 @@ function formatProviderOption(item) {
138
115
  return `${item.provider} (${item.modelCount} models, ${authLabel})`;
139
116
  }
140
117
 
141
- function formatModelOption(model) {
142
- const capabilities = [model.reasoning ? "reasoning" : null, model.input?.includes("image") ? "image" : null].filter(Boolean).join(", ");
143
- return capabilities ? `${model.id} [${capabilities}]` : model.id;
144
- }
145
-
146
118
  function selectPiLoginOption(options = []) {
147
119
  return options.find((option) => /device/i.test(`${option.id} ${option.label}`))
148
120
  || options.find((option) => /browser|oauth|web/i.test(`${option.id} ${option.label}`))
@@ -215,7 +187,7 @@ async function collectCliBootstrapChoices({ telegramApiKey, rl, ask }) {
215
187
  const models = sortBootstrapModels(selectedProvider.provider, listProviderModels(selectedProvider.provider, runtime));
216
188
  console.log(`\nAvailable models for ${selectedProvider.provider}:`);
217
189
  models.forEach((model, index) => {
218
- console.log(`${index + 1}. ${formatModelOption(model)}`);
190
+ console.log(`${index + 1}. ${formatPiModelOption(model)}`);
219
191
  });
220
192
 
221
193
  const selectedModel = selectByIndex(models, await ask("Select Pi model by number", "1"));
@@ -326,14 +298,20 @@ async function runTelegramBootstrap({ telegramApiKey, setupToken, botInfo }) {
326
298
  const askProvider = async (ctx = null, page = 0) => {
327
299
  state = "provider";
328
300
  await showSetupPrompt(ctx, "Select the Pi provider Arisa should use:", {
329
- reply_markup: buildPagedInlineKeyboard("provider", providers.map((provider) => ({ text: formatProviderOption(provider) })), { page })
301
+ reply_markup: buildPagedInlineKeyboard("provider", providers.map((provider) => ({ text: formatProviderOption(provider) })), {
302
+ page,
303
+ pageSize: telegramConfigDefaults.modelPickerPageSize
304
+ })
330
305
  });
331
306
  };
332
307
 
333
308
  const askModel = async (ctx = null, page = 0) => {
334
309
  state = "model";
335
310
  const models = sortBootstrapModels(selectedProvider.provider, listProviderModels(selectedProvider.provider, createPiRuntime()));
336
- const keyboard = buildPagedInlineKeyboard("model", models.map((model) => ({ text: formatModelOption(model) })), { page });
311
+ const keyboard = buildPagedInlineKeyboard("model", models.map((model) => ({ text: formatPiModelOption(model) })), {
312
+ page,
313
+ pageSize: telegramConfigDefaults.modelPickerPageSize
314
+ });
337
315
  keyboard.inline_keyboard.push([{ text: "Back to providers", callback_data: "back:provider" }]);
338
316
  await showSetupPrompt(ctx, `Select the model for ${selectedProvider.provider}:`, {
339
317
  reply_markup: keyboard
@@ -72,8 +72,14 @@ export function getDaemonInstanceDir(toolName, scope = { type: "global" }) {
72
72
  : path.join(getChatToolStateDir(normalized.chatId, toolName), "daemon");
73
73
  }
74
74
 
75
- export function getChatPiSessionsDir(chatId) {
76
- return path.join(getChatDir(chatId), "state", "pi-sessions");
75
+ export function getChatPiSessionsDir(chatId, sessionRevision = 0) {
76
+ if (!Number.isSafeInteger(sessionRevision) || sessionRevision < 0) {
77
+ throw new Error(`Invalid Pi session revision: ${sessionRevision}`);
78
+ }
79
+ const sessionsDir = path.join(getChatDir(chatId), "state", "pi-sessions");
80
+ return sessionRevision === 0
81
+ ? sessionsDir
82
+ : path.join(sessionsDir, String(sessionRevision));
77
83
  }
78
84
 
79
85
  export function getToolDir(toolName) {
@@ -3,9 +3,12 @@ import path from "node:path";
3
3
  import { authorizeChat } from "./auth.js";
4
4
  import { captureIncomingArtifact, formatLocationText } from "./media.js";
5
5
  import { buildDeviceCodeTelegramMessage } from "./device-code-message.js";
6
+ import { buildModelPicker, parseModelPickerAction } from "./model-picker.js";
6
7
  import { renderTelegramHtml } from "./text-format.js";
7
8
  import { buildPiAuthRecoveryBlockedMessage, buildPiAuthTelegramMessage, getErrorMessage, getPiAuthIssue, getPiAuthStatus } from "../../core/agent/auth-flow.js";
8
9
  import { createPiOAuthLogin } from "../../core/agent/pi-auth-login.js";
10
+ import { resolveChatModel, selectChatModel } from "../../core/agent/model-selection.js";
11
+ import { createPiRuntime, listProviderModels } from "../../core/agent/pi-runtime.js";
9
12
  import { normalizeArtifactForReasoning, shouldNormalizeArtifactToText } from "../../core/artifacts/normalize-for-reasoning.js";
10
13
 
11
14
  const slowPromptNoticeMs = 300_000;
@@ -410,6 +413,49 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
410
413
  return perChatState.get(chatId);
411
414
  }
412
415
 
416
+ function getProviderModels() {
417
+ const runtime = createPiRuntime({
418
+ provider: config.pi.provider,
419
+ apiKey: config.pi.apiKey
420
+ });
421
+ return listProviderModels(config.pi.provider, runtime);
422
+ }
423
+
424
+ async function showModelPicker(ctx, page = 0) {
425
+ const picker = buildModelPicker({
426
+ provider: config.pi.provider,
427
+ models: getProviderModels(),
428
+ selectedModelId: resolveChatModel(config, ctx.chat.id),
429
+ page,
430
+ pageSize: config.telegram.modelPickerPageSize
431
+ });
432
+ const extra = { reply_markup: picker.replyMarkup };
433
+ const messageId = ctx.callbackQuery?.message?.message_id;
434
+ if (messageId) {
435
+ return ctx.api.editMessageText(ctx.chat.id, messageId, picker.text, extra);
436
+ }
437
+ return ctx.reply(picker.text, extra);
438
+ }
439
+
440
+ async function persistChatModel(chatId, model) {
441
+ const key = chatKey(chatId);
442
+ const hadSelections = Boolean(config.pi.chatModels);
443
+ const previousSelection = config.pi.chatModels?.[key];
444
+ selectChatModel(config, chatId, model);
445
+ try {
446
+ await saveConfig(config);
447
+ } catch (error) {
448
+ if (previousSelection) {
449
+ config.pi.chatModels[key] = previousSelection;
450
+ } else {
451
+ delete config.pi.chatModels[key];
452
+ if (!hadSelections) delete config.pi.chatModels;
453
+ }
454
+ throw error;
455
+ }
456
+ agentManager.resetSession(chatId);
457
+ }
458
+
413
459
  async function buildIncomingPrompt(ctx) {
414
460
  const chatId = ctx.chat.id;
415
461
  logger?.log("telegram", `message ${ctx.msg.message_id} in chat ${chatId}`);
@@ -684,6 +730,12 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
684
730
  await handleNewCommand(ctx);
685
731
  });
686
732
 
733
+ bot.command("model", async (ctx) => {
734
+ const auth = await authorizeChat({ config, chatId: ctx.chat.id, saveConfig, chatMeta: getIncomingChatMeta(ctx) });
735
+ if (!auth.ok) return;
736
+ await showModelPicker(ctx);
737
+ });
738
+
687
739
  bot.command("auth", async (ctx) => {
688
740
  const auth = await authorizeChat({ config, chatId: ctx.chat.id, saveConfig, chatMeta: getIncomingChatMeta(ctx) });
689
741
  if (!auth.ok) return;
@@ -717,6 +769,67 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
717
769
  }
718
770
  });
719
771
 
772
+ bot.on("callback_query:data", async (ctx, next) => {
773
+ const action = parseModelPickerAction(ctx.callbackQuery.data);
774
+ if (!action) return next();
775
+ if (action.type === "noop") {
776
+ await ctx.answerCallbackQuery();
777
+ return;
778
+ }
779
+
780
+ const auth = await authorizeChat({ config, chatId: ctx.chat.id, saveConfig });
781
+ if (!auth.ok) {
782
+ await ctx.answerCallbackQuery({ text: "This chat is not authorized.", show_alert: true });
783
+ return;
784
+ }
785
+
786
+ try {
787
+ if (action.type === "page") {
788
+ await showModelPicker(ctx, action.value);
789
+ await ctx.answerCallbackQuery();
790
+ return;
791
+ }
792
+
793
+ if (getChatState(ctx.chat.id).processing) {
794
+ await ctx.answerCallbackQuery({
795
+ text: "Wait for the current response before changing models.",
796
+ show_alert: true
797
+ });
798
+ return;
799
+ }
800
+
801
+ const models = getProviderModels();
802
+ const model = models[action.value];
803
+ if (!model) {
804
+ await ctx.answerCallbackQuery({
805
+ text: "This model list is no longer current. Run /model again.",
806
+ show_alert: true
807
+ });
808
+ return;
809
+ }
810
+
811
+ const currentModelId = resolveChatModel(config, ctx.chat.id);
812
+ if (model.id === currentModelId) {
813
+ await ctx.answerCallbackQuery({ text: `Already using ${model.id}.` });
814
+ return;
815
+ }
816
+
817
+ await persistChatModel(ctx.chat.id, model);
818
+ await ctx.api.editMessageText(
819
+ ctx.chat.id,
820
+ ctx.callbackQuery.message.message_id,
821
+ `Model changed to ${model.provider}/${model.id}.\nA new chat context will start with your next message.`
822
+ );
823
+ await ctx.answerCallbackQuery({ text: `Using ${model.id}.` });
824
+ } catch (error) {
825
+ logger?.error("telegram", `model selection failed for chat ${ctx.chat.id}: ${getErrorMessage(error)}`);
826
+ await ctx.answerCallbackQuery({
827
+ text: "Could not change the model.",
828
+ show_alert: true
829
+ }).catch(() => {});
830
+ }
831
+ });
832
+
720
833
  bot.on("message", async (ctx) => {
721
834
  const auth = await authorizeChat({ config, chatId: ctx.chat.id, saveConfig, chatMeta: getIncomingChatMeta(ctx) });
722
835
  if (!auth.ok) return;
@@ -753,6 +866,7 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
753
866
  config.telegram.chatMeta ||= {};
754
867
  await bot.api.setMyCommands([
755
868
  { command: "new", description: "Start a new chat context" },
869
+ { command: "model", description: "Choose the model for this chat" },
756
870
  { command: "auth", description: "Show Pi authentication status" }
757
871
  ]);
758
872
  if (!taskTimer) {
@@ -0,0 +1,25 @@
1
+ import { formatPiModelOption } from "../../core/agent/pi-runtime.js";
2
+ import { buildPagedInlineKeyboard } from "./paged-inline-keyboard.js";
3
+
4
+ export function parseModelPickerAction(data) {
5
+ if (data === "noop:page") return { type: "noop", value: null };
6
+ const match = /^(model|model-page):(\d+)$/.exec(String(data || ""));
7
+ if (!match) return null;
8
+ return {
9
+ type: match[1] === "model" ? "select" : "page",
10
+ value: Number(match[2])
11
+ };
12
+ }
13
+
14
+ export function buildModelPicker({ provider, models, selectedModelId, page, pageSize }) {
15
+ if (!models.length) {
16
+ throw new Error(`No models available for provider ${provider}`);
17
+ }
18
+ const items = models.map((model) => ({
19
+ text: `${model.id === selectedModelId ? "✓ " : ""}${formatPiModelOption(model)}`
20
+ }));
21
+ return {
22
+ text: `Current model: ${provider}/${selectedModelId}\nSelect a model for this chat:`,
23
+ replyMarkup: buildPagedInlineKeyboard("model", items, { page, pageSize })
24
+ };
25
+ }
@@ -0,0 +1,30 @@
1
+ function requirePositiveInteger(value, name) {
2
+ if (!Number.isInteger(value) || value <= 0) {
3
+ throw new Error(`${name} must be a positive integer`);
4
+ }
5
+ }
6
+
7
+ export function buildPagedInlineKeyboard(action, items, { page = 0, pageSize }) {
8
+ requirePositiveInteger(pageSize, "pageSize");
9
+ const pageCount = Math.max(1, Math.ceil(items.length / pageSize));
10
+ const currentPage = Math.max(0, Math.min(pageCount - 1, page));
11
+ const startIndex = currentPage * pageSize;
12
+ const rows = items.slice(startIndex, startIndex + pageSize).map((item, index) => ([{
13
+ text: item.text,
14
+ callback_data: `${action}:${startIndex + index}`
15
+ }]));
16
+
17
+ if (pageCount > 1) {
18
+ const navigation = [];
19
+ if (currentPage > 0) {
20
+ navigation.push({ text: "Previous", callback_data: `${action}-page:${currentPage - 1}` });
21
+ }
22
+ navigation.push({ text: `${currentPage + 1}/${pageCount}`, callback_data: "noop:page" });
23
+ if (currentPage < pageCount - 1) {
24
+ navigation.push({ text: "Next", callback_data: `${action}-page:${currentPage + 1}` });
25
+ }
26
+ rows.push(navigation);
27
+ }
28
+
29
+ return { inline_keyboard: rows };
30
+ }
@@ -116,6 +116,38 @@ test("isolates daemon process files and context by chat scope", async () => {
116
116
  await Promise.all([first.stop(), second.stop()]);
117
117
  });
118
118
 
119
+ test("supervisor ignores invalid chat directories and recovers valid daemons", async () => {
120
+ const invalidChatDir = path.join(homeDir, "chats", "24137857-c513-4f53-b39d-1b28f51ebbb6");
121
+ await mkdir(path.join(invalidChatDir, "state", "tools", "orphaned-tool"), { recursive: true });
122
+
123
+ const runtime = runtimeFor({ type: "chat", chatId: "202" }, {
124
+ autoStart: true,
125
+ startupContext: { health: "ok" }
126
+ });
127
+ let supervisor;
128
+
129
+ try {
130
+ await runtime.submit({ value: "before" }, { timeoutMs: 1_000 });
131
+ const oldPid = await runtime.getPid();
132
+ process.kill(oldPid, "SIGKILL");
133
+ await waitFor(() => !isProcessAlive(oldPid));
134
+
135
+ supervisor = createToolProcessSupervisor({ policy });
136
+ await supervisor.start();
137
+
138
+ const newPid = await waitFor(async () => {
139
+ const pid = await runtime.getPid();
140
+ const status = await readJson(runtime.paths.statusFile, {});
141
+ return pid && pid !== oldPid && status.state === "ready" ? pid : null;
142
+ });
143
+ assert.notEqual(newPid, oldPid);
144
+ } finally {
145
+ await supervisor?.stop();
146
+ await runtime.stop().catch(() => {});
147
+ await rm(invalidChatDir, { recursive: true, force: true });
148
+ }
149
+ });
150
+
119
151
  test("rejects stale readiness even when the pid is alive", async () => {
120
152
  const status = {
121
153
  state: "ready",
@@ -0,0 +1,99 @@
1
+ import assert from "node:assert/strict";
2
+ import path from "node:path";
3
+ import test from "node:test";
4
+ import { resolveChatModel, selectChatModel } from "../src/core/agent/model-selection.js";
5
+ import { applyConfigDefaults, telegramConfigDefaults } from "../src/core/config/config-defaults.js";
6
+ import { getChatPiSessionsDir } from "../src/runtime/paths.js";
7
+ import { buildModelPicker, parseModelPickerAction } from "../src/transport/telegram/model-picker.js";
8
+
9
+ function createConfig() {
10
+ return {
11
+ telegram: {},
12
+ pi: {
13
+ provider: "openai-codex",
14
+ model: "gpt-default"
15
+ }
16
+ };
17
+ }
18
+
19
+ test("resolves the default model until a chat selects one", () => {
20
+ const config = createConfig();
21
+
22
+ assert.equal(resolveChatModel(config, 123), "gpt-default");
23
+
24
+ selectChatModel(config, 123, { provider: "openai-codex", id: "gpt-selected" });
25
+
26
+ assert.equal(resolveChatModel(config, 123), "gpt-selected");
27
+ assert.equal(resolveChatModel(config, 456), "gpt-default");
28
+ assert.deepEqual(config.pi.chatModels["123"], {
29
+ provider: "openai-codex",
30
+ model: "gpt-selected",
31
+ sessionRevision: 1
32
+ });
33
+ });
34
+
35
+ test("starts a distinct persisted Pi session revision on every model change", () => {
36
+ const config = createConfig();
37
+
38
+ selectChatModel(config, 123, { provider: "openai-codex", id: "gpt-a" });
39
+ selectChatModel(config, 123, { provider: "openai-codex", id: "gpt-b" });
40
+
41
+ assert.equal(config.pi.chatModels["123"].sessionRevision, 2);
42
+ assert.equal(
43
+ getChatPiSessionsDir(123, config.pi.chatModels["123"].sessionRevision),
44
+ path.join(getChatPiSessionsDir(123), "2")
45
+ );
46
+ });
47
+
48
+ test("ignores a chat selection from a different active provider", () => {
49
+ const config = createConfig();
50
+ config.pi.chatModels = {
51
+ 123: { provider: "anthropic", model: "claude-selected" }
52
+ };
53
+
54
+ assert.equal(resolveChatModel(config, 123), "gpt-default");
55
+ });
56
+
57
+ test("rejects selecting a model outside the active provider", () => {
58
+ const config = createConfig();
59
+
60
+ assert.throws(
61
+ () => selectChatModel(config, 123, { provider: "anthropic", id: "claude-selected" }),
62
+ /active provider is openai-codex/
63
+ );
64
+ });
65
+
66
+ test("builds a paged model picker and marks the current model", () => {
67
+ const models = [
68
+ { provider: "openai-codex", id: "gpt-a", reasoning: false, input: ["text"] },
69
+ { provider: "openai-codex", id: "gpt-b", reasoning: true, input: ["text", "image"] },
70
+ { provider: "openai-codex", id: "gpt-c", reasoning: false, input: ["text"] }
71
+ ];
72
+
73
+ const picker = buildModelPicker({
74
+ provider: "openai-codex",
75
+ models,
76
+ selectedModelId: "gpt-b",
77
+ page: 0,
78
+ pageSize: 2
79
+ });
80
+
81
+ assert.match(picker.text, /openai-codex\/gpt-b/);
82
+ assert.equal(picker.replyMarkup.inline_keyboard[0][0].callback_data, "model:0");
83
+ assert.match(picker.replyMarkup.inline_keyboard[1][0].text, /^✓ gpt-b \[reasoning, image\]$/);
84
+ assert.equal(picker.replyMarkup.inline_keyboard[2][1].callback_data, "model-page:1");
85
+ });
86
+
87
+ test("parses only model picker callback data", () => {
88
+ assert.deepEqual(parseModelPickerAction("model:12"), { type: "select", value: 12 });
89
+ assert.deepEqual(parseModelPickerAction("model-page:2"), { type: "page", value: 2 });
90
+ assert.deepEqual(parseModelPickerAction("noop:page"), { type: "noop", value: null });
91
+ assert.equal(parseModelPickerAction("provider:1"), null);
92
+ assert.equal(parseModelPickerAction("model:-1"), null);
93
+ });
94
+
95
+ test("centralizes the model picker page size in Telegram config defaults", () => {
96
+ const config = applyConfigDefaults(createConfig());
97
+
98
+ assert.equal(config.telegram.modelPickerPageSize, telegramConfigDefaults.modelPickerPageSize);
99
+ });