diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index 61300b85b5..4fe0672645 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -38,6 +38,7 @@ export default defineConfig({ '@mariozechner/pi-agent-core', '@mariozechner/pi-ai', '@mariozechner/pi-coding-agent', + 'grammy', ], }, }, diff --git a/src/agent/async-agent.ts b/src/agent/async-agent.ts index 57f26bac53..80cd45c652 100644 --- a/src/agent/async-agent.ts +++ b/src/agent/async-agent.ts @@ -73,15 +73,25 @@ export class AsyncAgent { if (result.error) { console.error(`[AsyncAgent] Agent run error: ${result.error}`); this.channel.send({ id: uuidv7(), content: `[error] ${result.error}` }); - this.agent.emitError(result.error); + // Only emit agent_error for HTTP 401 from the LLM provider so the + // UI shows the "Configure" banner. All other errors (400, tool errors, + // etc.) should flow back to the agent for self-recovery. + if (/\b401\b/.test(result.error)) { + this.agent.emitError(result.error); + } } }) .catch((err) => { const message = err instanceof Error ? err.message : String(err); console.error(`[AsyncAgent] Agent run exception: ${message}`); this.channel.send({ id: uuidv7(), content: `[error] ${message}` }); - // Also emit through subscriber mechanism so IPC listeners receive the error - this.agent.emitError(message); + // Only emit agent_error for HTTP 401 from the LLM provider so the + // UI shows the "Configure" banner. All other errors (400, tool errors, + // etc.) should flow back to the agent for self-recovery. + const errMsg = err instanceof Error ? err.message : String(err); + if (/\b401\b/.test(errMsg)) { + this.agent.emitError(message); + } }) .finally(() => { this.pendingWrites = Math.max(0, this.pendingWrites - 1); diff --git a/src/agent/runner.ts b/src/agent/runner.ts index d2a164d4ad..732daacb51 100644 --- a/src/agent/runner.ts +++ b/src/agent/runner.ts @@ -112,8 +112,15 @@ export class Agent { this.reasoningMode = options.reasoningMode ?? "stream"; this.output = createAgentOutput({ stdout, stderr: this.stderr, reasoningMode: this.reasoningMode }); - // Resolve provider and model from options > env vars > defaults - const defaultProvider = options.provider ?? credentialManager.getLlmProvider() ?? "kimi-coding"; + // Load session metadata early so stored provider/model can inform defaults + this.sessionId = options.sessionId ?? uuidv7(); + const storedMeta = (() => { + const tempSession = new SessionManager({ sessionId: this.sessionId }); + return tempSession.getMeta(); + })(); + + // Resolve provider and model from options > session meta > env vars > defaults + const defaultProvider = options.provider ?? storedMeta?.provider ?? credentialManager.getLlmProvider() ?? "kimi-coding"; if (options.authProfileId) { const profileProvider = options.authProfileId.includes(":") ? options.authProfileId.split(":")[0]! @@ -195,16 +202,7 @@ export class Agent { }); } - this.sessionId = options.sessionId ?? uuidv7(); - - // 解析 model(用于获取 context window) - const storedMeta = (() => { - // 临时创建 session 获取 meta,避免循环依赖 - const tempSession = new SessionManager({ sessionId: this.sessionId }); - return tempSession.getMeta(); - })(); - - const effectiveProvider = resolvedModel ? this.resolvedProvider : (options.provider ?? storedMeta?.provider); + const effectiveProvider = this.resolvedProvider; const effectiveModel = resolvedModel ?? options.model ?? storedMeta?.model; let model = resolveModel({ ...options, provider: effectiveProvider, model: effectiveModel }); @@ -319,7 +317,7 @@ export class Agent { } this.session.saveMeta({ - provider: this.agent.state.model?.provider, + provider: this.resolvedProvider, model: this.agent.state.model?.id, thinkingLevel: this.agent.state.thinkingLevel, reasoningMode: this.reasoningMode, @@ -889,9 +887,9 @@ export class Agent { // Update internal state this.resolvedProvider = providerId; - // Update session metadata + // Update session metadata (save original providerId, not alias-resolved) this.session.saveMeta({ - provider: actualProvider, + provider: providerId, model: model.id, thinkingLevel: this.agent.state.thinkingLevel, reasoningMode: this.reasoningMode, diff --git a/src/heartbeat/runner.test.ts b/src/heartbeat/runner.test.ts index 8a3570a3d6..1a04bc5ff2 100644 --- a/src/heartbeat/runner.test.ts +++ b/src/heartbeat/runner.test.ts @@ -65,6 +65,18 @@ describe("heartbeat runner", () => { } }); + it("bypasses empty-heartbeat-file check for cron-triggered wakes", async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), "heartbeat-test-")); + try { + await writeFile(path.join(dir, "heartbeat.md"), "# keep empty\n", "utf-8"); + const agent = createStubAgent({ profileDir: dir, replyText: "HEARTBEAT_OK" }); + const result = await runHeartbeatOnce({ agent: agent as any, reason: "cron:test-job-id" }); + expect(result.status).toBe("ran"); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + it("runs and returns ran for heartbeat acknowledgements", async () => { const agent = createStubAgent({ replyText: "HEARTBEAT_OK" }); const result = await runHeartbeatOnce({ agent: agent as any, reason: "manual" }); diff --git a/src/heartbeat/runner.ts b/src/heartbeat/runner.ts index f9ba6bfa1d..7e6dab5368 100644 --- a/src/heartbeat/runner.ts +++ b/src/heartbeat/runner.ts @@ -156,8 +156,8 @@ export async function runHeartbeatOnce(opts: { } try { - const isExecEvent = opts.reason === "exec-event"; - if (!isExecEvent && (await isHeartbeatFileEmpty(agent))) { + const isForcedWake = opts.reason === "exec-event" || opts.reason?.startsWith("cron:"); + if (!isForcedWake && (await isHeartbeatFileEmpty(agent))) { emitHeartbeatEvent({ status: "skipped", reason: "empty-heartbeat-file",