diff --git a/src/agent/check-approval.ts b/src/agent/check-approval.ts index ef50d9c..7b2801d 100644 --- a/src/agent/check-approval.ts +++ b/src/agent/check-approval.ts @@ -6,7 +6,7 @@ import { APIError } from "@letta-ai/letta-client/core/error"; import type { AgentState } from "@letta-ai/letta-client/resources/agents/agents"; import type { Message } from "@letta-ai/letta-client/resources/agents/messages"; import type { ApprovalRequest } from "../cli/helpers/stream"; -import { debugWarn, isDebugEnabled } from "../utils/debug"; +import { debugLog, debugWarn, isDebugEnabled } from "../utils/debug"; // Backfill should feel like "the last turn(s)", not "the last N raw messages". // Tool-heavy turns can generate many tool_call/tool_return messages that would @@ -485,8 +485,11 @@ export async function getResumeData( messages = sortChronological(messagesPage.getPaginatedItems()); if (isDebugEnabled()) { - console.log( - `[DEBUG] conversations.messages.list(default, agent_id=${agent.id}) returned ${messages.length} messages`, + debugLog( + "check-approval", + "conversations.messages.list(default, agent_id=%s) returned %d messages", + agent.id, + messages.length, ); } } catch (backfillError) { diff --git a/src/agent/message.ts b/src/agent/message.ts index 7359652..972ca0b 100644 --- a/src/agent/message.ts +++ b/src/agent/message.ts @@ -139,23 +139,30 @@ export async function sendMessageStream( ); if (isDebugEnabled()) { - console.log( - `[DEBUG] sendMessageStream: conversationId=${conversationId}, agentId=${opts.agentId ?? "(none)"}`, + debugLog( + "agent-message", + "sendMessageStream: conversationId=%s, agentId=%s", + conversationId, + opts.agentId ?? "(none)", ); const formattedSkills = clientSkills.map( (skill) => `${skill.name} (${skill.location})`, ); - console.log( - `[DEBUG] sendMessageStream: client_skills (${clientSkills.length}) ${ - formattedSkills.length > 0 ? formattedSkills.join(", ") : "(none)" - }`, + debugLog( + "agent-message", + "sendMessageStream: client_skills (%d) %s", + clientSkills.length, + formattedSkills.length > 0 ? formattedSkills.join(", ") : "(none)", ); if (clientSkillDiscoveryErrors.length > 0) { for (const error of clientSkillDiscoveryErrors) { - console.warn( - `[DEBUG] sendMessageStream: client_skills discovery error at ${error.path}: ${error.message}`, + debugWarn( + "agent-message", + "sendMessageStream: client_skills discovery error at %s: %s", + error.path, + error.message, ); } } diff --git a/src/cli/App.tsx b/src/cli/App.tsx index 90bd45b..51ec61e 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -3056,8 +3056,11 @@ export default function App({ const isResumingConversation = resumedExistingConversation || messageHistory.length > 0; if (isDebugEnabled()) { - console.log( - `[DEBUG] Header: resumedExistingConversation=${resumedExistingConversation}, messageHistory.length=${messageHistory.length}`, + debugLog( + "app", + "Header: resumedExistingConversation=%o, messageHistory.length=%d", + resumedExistingConversation, + messageHistory.length, ); } const headerMessage = isResumingConversation diff --git a/src/index.ts b/src/index.ts index 7a577c2..0313607 100755 --- a/src/index.ts +++ b/src/index.ts @@ -1825,10 +1825,12 @@ async function main(): Promise { // Debug: log resume flag status if (isDebugEnabled()) { - console.log(`[DEBUG] shouldContinue=${shouldContinue}`); - console.log(`[DEBUG] shouldResume=${shouldResume}`); - console.log( - `[DEBUG] specifiedConversationId=${specifiedConversationId}`, + debugLog("startup", "shouldContinue=%o", shouldContinue); + debugLog("startup", "shouldResume=%o", shouldResume); + debugLog( + "startup", + "specifiedConversationId=%s", + specifiedConversationId, ); } @@ -1866,8 +1868,8 @@ async function main(): Promise { settingsManager.getGlobalLastSession(); if (isDebugEnabled()) { - console.log(`[DEBUG] lastSession=${JSON.stringify(lastSession)}`); - console.log(`[DEBUG] agent.id=${agent.id}`); + debugLog("startup", "lastSession=%s", JSON.stringify(lastSession)); + debugLog("startup", "agent.id=%s", agent.id); } let resumedSuccessfully = false; diff --git a/src/tests/cli/reflection-transcript.test.ts b/src/tests/cli/reflection-transcript.test.ts index db2f084..1face9d 100644 --- a/src/tests/cli/reflection-transcript.test.ts +++ b/src/tests/cli/reflection-transcript.test.ts @@ -133,6 +133,7 @@ describe("reflectionTranscript helper", () => { test("buildParentMemorySnapshot renders tree descriptions and system blocks", async () => { const memoryDir = join(testRoot, "memory"); + const normalizedMemoryDir = memoryDir.replace(/\\/g, "/"); await mkdir(join(memoryDir, "system"), { recursive: true }); await mkdir(join(memoryDir, "reference"), { recursive: true }); await mkdir(join(memoryDir, "skills", "bird"), { recursive: true }); @@ -165,12 +166,14 @@ describe("reflectionTranscript helper", () => { expect(snapshot).toContain("SKILL.md (X/Twitter CLI for posting)"); expect(snapshot).toContain(""); - expect(snapshot).toContain(`${memoryDir}/system/human.md`); + expect(snapshot).toContain( + `${normalizedMemoryDir}/system/human.md`, + ); expect(snapshot).toContain("Dr. Wooders prefers direct answers."); expect(snapshot).toContain(""); expect(snapshot).not.toContain( - `${memoryDir}/reference/project.md`, + `${normalizedMemoryDir}/reference/project.md`, ); expect(snapshot).not.toContain("letta-code CLI details"); expect(snapshot).not.toContain( diff --git a/src/tests/shell-codex.test.ts b/src/tests/shell-codex.test.ts index 8e85d30..f83c8eb 100644 --- a/src/tests/shell-codex.test.ts +++ b/src/tests/shell-codex.test.ts @@ -34,6 +34,22 @@ describe("shell codex tool", () => { expect(result.output).toContain("hello from bash"); }); + test.skipIf(isWindows)( + "falls back when env-wrapped shell launcher is missing", + async () => { + const result = await shell({ + command: [ + "/definitely-missing/env", + "bash", + "-lc", + "echo env-fallback", + ], + }); + + expect(result.output).toContain("env-fallback"); + }, + ); + test("handles arguments with spaces correctly", async () => { // This is the key test for execvp semantics - args with spaces // should NOT be split @@ -136,6 +152,18 @@ describe("shell codex tool", () => { } }); + test.skipIf(isWindows)( + "falls back to the default cwd when workdir does not exist", + async () => { + const result = await shell({ + command: ["pwd"], + workdir: "/definitely/missing/path", + }); + + expect(result.output).toBe(process.env.USER_CWD || process.cwd()); + }, + ); + test.skipIf(isWindows)( "handles command that produces multi-line output", async () => { diff --git a/src/tools/impl/Shell.ts b/src/tools/impl/Shell.ts index dee0386..28bab5a 100644 --- a/src/tools/impl/Shell.ts +++ b/src/tools/impl/Shell.ts @@ -1,3 +1,4 @@ +import { existsSync, statSync } from "node:fs"; import * as path from "node:path"; import { getShellEnv } from "./shellEnv.js"; import { buildShellLaunchers } from "./shellLaunchers.js"; @@ -76,11 +77,13 @@ export async function shell(args: ShellArgs): Promise { } const timeout = timeout_ms ?? DEFAULT_TIMEOUT; - const cwd = workdir + const defaultCwd = process.env.USER_CWD || process.cwd(); + const requestedCwd = workdir ? path.isAbsolute(workdir) ? workdir - : path.resolve(process.env.USER_CWD || process.cwd(), workdir) - : process.env.USER_CWD || process.cwd(); + : path.resolve(defaultCwd, workdir) + : defaultCwd; + const cwd = isUsableDirectory(requestedCwd) ? requestedCwd : defaultCwd; const context: SpawnContext = { command, @@ -115,10 +118,9 @@ export async function shell(args: ShellArgs): Promise { function buildFallbackCommands(command: string[]): string[][] { if (!command.length) return []; - const first = command[0]; - if (!first) return []; - if (!isShellExecutableName(first)) return []; - const script = extractShellScript(command); + const shellIndex = findShellExecutableIndex(command); + if (shellIndex === null) return []; + const script = extractShellScript(command, shellIndex); if (!script) return []; const launchers = buildShellLaunchers(script); return launchers.filter((launcher) => !arraysEqual(launcher, command)); @@ -132,6 +134,14 @@ function arraysEqual(a: string[], b: string[]): boolean { return true; } +function isUsableDirectory(candidate: string): boolean { + try { + return existsSync(candidate) && statSync(candidate).isDirectory(); + } catch { + return false; + } +} + function isShellExecutableName(name: string): boolean { const normalized = name.replace(/\\/g, "/").toLowerCase(); if (/(^|\/)(ba|z|a|da)?sh$/.test(normalized)) { @@ -149,8 +159,33 @@ function isShellExecutableName(name: string): boolean { return false; } -function extractShellScript(command: string[]): string | null { +function isEnvExecutableName(name: string): boolean { + const normalized = name.replace(/\\/g, "/").toLowerCase(); + return normalized === "env" || normalized.endsWith("/env"); +} + +function findShellExecutableIndex(command: string[]): number | null { + const first = command[0]; + if (!first) return null; + if (isShellExecutableName(first)) return 0; + if (!isEnvExecutableName(first)) return null; + for (let i = 1; i < command.length; i += 1) { + const token = command[i]; + if (!token) continue; + if (token.startsWith("-")) continue; + if (/^[A-Za-z_][A-Za-z0-9_]*=.*/.test(token)) continue; + return isShellExecutableName(token) ? i : null; + } + + return null; +} + +function extractShellScript( + command: string[], + shellIndex: number, +): string | null { + for (let i = shellIndex + 1; i < command.length; i += 1) { const token = command[i]; if (!token) continue; const normalized = token.toLowerCase();