diff --git a/packages/happy-agent-modules/sources/compute/tools/codex/impl/applyCodexPatch.ts b/packages/happy-agent-modules/sources/compute/tools/codex/impl/applyCodexPatch.ts index 35d4e8c5..03ba4120 100644 --- a/packages/happy-agent-modules/sources/compute/tools/codex/impl/applyCodexPatch.ts +++ b/packages/happy-agent-modules/sources/compute/tools/codex/impl/applyCodexPatch.ts @@ -13,7 +13,7 @@ import { moveComputeFile } from "../../../impl/moveComputeFile.js"; import { iterateDiffContentLines } from "../../../impl/iterateDiffContentLines.js"; import { resolveComputePath } from "../../../impl/resolveComputePath.js"; import { writeComputeTextFile } from "../../../impl/writeComputeTextFile.js"; -import { parseCodexPatch, type CodexPatchHunk } from "./parseCodexPatch.js"; +import { parseCodexPatch, type CodexPatchHunk } from "../../../../impl/parseCodexPatch.js"; /** One file the patch changed, named both as the patch wrote it and as the machine sees it. */ export interface CodexPatchChange { diff --git a/packages/happy-agent-modules/sources/compute/tools/codex/impl/codexPatchPaths.ts b/packages/happy-agent-modules/sources/compute/tools/codex/impl/codexPatchPaths.ts index c79195c5..4fc51892 100644 --- a/packages/happy-agent-modules/sources/compute/tools/codex/impl/codexPatchPaths.ts +++ b/packages/happy-agent-modules/sources/compute/tools/codex/impl/codexPatchPaths.ts @@ -1,5 +1,5 @@ import { resolveComputePath } from "../../../impl/resolveComputePath.js"; -import { parseCodexPatchDirective } from "./parseCodexPatch.js"; +import { parseCodexPatchDirective } from "../../../../impl/parseCodexPatch.js"; /** * Every file a patch names, as absolute paths on the machine. diff --git a/packages/happy-agent-modules/sources/happy/HappyProtocol.ts b/packages/happy-agent-modules/sources/happy/HappyProtocol.ts index dfddb291..96355fd1 100644 --- a/packages/happy-agent-modules/sources/happy/HappyProtocol.ts +++ b/packages/happy-agent-modules/sources/happy/HappyProtocol.ts @@ -26,7 +26,7 @@ export type HappySessionEvent = // and a truer-looking event does not. | { t: "service"; text: string } | { t: "text"; text: string; thinking?: boolean } - | { t: "tool-call-end"; call: string } + | { t: "tool-call-end"; call: string; result?: string; isError?: boolean } | { t: "tool-call-start"; args: Record; diff --git a/packages/happy-agent-modules/sources/happy/LEARNINGS.md b/packages/happy-agent-modules/sources/happy/LEARNINGS.md index 43caaf4b..8ad695cf 100644 --- a/packages/happy-agent-modules/sources/happy/LEARNINGS.md +++ b/packages/happy-agent-modules/sources/happy/LEARNINGS.md @@ -1,5 +1,18 @@ # Happy module learnings +## Mobile tool wire normalization + +- Normalize tool calls at the Happy sync boundary only when the mobile app already owns the same + semantic renderer and argument contract. Preserve every other real tool name and send a precise + activity description through the generic canonical tool-call envelope; a familiar but false + tool shape is worse than an honest generic row. +- Parse Codex `apply_patch` text inside Happy Agent and send the established mobile + `CodexPatch { changes }` payload. Mobile clients should render structured file changes and must + never need to parse Codex's patch grammar. +- Send Codex update hunks as `modify { old_content, new_content }`, which mobile already routes + through the same paired, intra-line diff renderer as Claude `Edit`. A raw unified patch sends + native mobile down its simpler prefix-colored fallback instead. + ## Pairing and public state - Resolve the Happy CLI home and server URL through `ConfigModule` even before credentials exist. Reading `process.env` directly bypasses daemon-owned environment overrides and can accidentally inspect another installation during hermetic tests. diff --git a/packages/happy-agent-modules/sources/happy/mapHappyMessages.ts b/packages/happy-agent-modules/sources/happy/mapHappyMessages.ts index 7f6b7445..6fd314a3 100644 --- a/packages/happy-agent-modules/sources/happy/mapHappyMessages.ts +++ b/packages/happy-agent-modules/sources/happy/mapHappyMessages.ts @@ -1,6 +1,5 @@ import { Type } from "@sinclair/typebox"; import { Value } from "@sinclair/typebox/value"; - import type { AgentEvent } from "../events/index.js"; import type { HistoryMessage } from "../history/index.js"; import type { @@ -9,6 +8,7 @@ import type { HappySessionProtocolMessage, HappyUsage, } from "./HappyProtocol.js"; +import { happyToolCallPresentation, normalizeHappyToolCall } from "./normalizeHappyToolCall.js"; /** How many event ids are remembered so a replayed event is not shown twice. */ const MAX_REMEMBERED_EVENTS = 16_384; @@ -70,7 +70,11 @@ const toolEndSchema = Type.Object( rigEvent: Type.Object( { result: Type.Object( - { toolCallId: Type.String({ minLength: 1 }) }, + { + display: Type.Optional(Type.String()), + isError: Type.Optional(Type.Boolean()), + toolCallId: Type.String({ minLength: 1 }), + }, { additionalProperties: true }, ), type: Type.Literal("tool_execution_end"), @@ -253,7 +257,7 @@ export class HappyMessageMapper { if (block.type === "tool_result") { output.push( this.#createMessage({ - ev: this.#toolResultEvent(block.callId), + ev: this.#toolResultEvent(block.callId, block.display, block.isError), id, role: "agent", time, @@ -322,9 +326,13 @@ export class HappyMessageMapper { ]; } if (Value.Check(toolEndSchema, event.payload)) { - const callId = event.payload.rigEvent.result.toolCallId; + const result = event.payload.rigEvent.result; return [ - this.#agentMessage(event, `tool-result:${callId}`, this.#toolResultEvent(callId)), + this.#agentMessage( + event, + `tool-result:${result.toolCallId}`, + this.#toolResultEvent(result.toolCallId, result.display, result.isError), + ), ]; } return []; @@ -335,19 +343,30 @@ export class HappyMessageMapper { id: string; name: string; }): Extract { - const title = humanizeToolName(call.name); + const args = call.arguments === undefined ? {} : toRecord(call.arguments); + const normalized = normalizeHappyToolCall(call.name, args); + const presentation = happyToolCallPresentation(call.name, normalized); return { - args: call.arguments === undefined ? {} : toRecord(call.arguments), + args: normalized.args, call: call.id, - description: `Running ${title}`, - name: call.name, + description: presentation.description, + name: normalized.name, t: "tool-call-start", - title, + title: presentation.title, }; } - #toolResultEvent(callId: string): Extract { - return { call: callId, t: "tool-call-end" }; + #toolResultEvent( + callId: string, + result?: string, + isError?: boolean, + ): Extract { + return { + call: callId, + ...(result === undefined ? {} : { result }), + ...(isError === true ? { isError: true } : {}), + t: "tool-call-end", + }; } #mapInference(event: AgentEvent): readonly HappySessionProtocolMessage[] { @@ -473,20 +492,6 @@ export class HappyMessageMapper { } } -/** Turns a tool's identifier into the words a person reads on the phone. */ -function humanizeToolName(value: string): string { - const spaced = value - .replaceAll(/[_-]+/gu, " ") - .replaceAll(/([a-z])([A-Z])/gu, "$1 $2") - .trim(); - return spaced.length === 0 - ? "Tool" - : spaced - .split(/\s+/u) - .map((part) => part[0]!.toUpperCase() + part.slice(1)) - .join(" "); -} - function toRecord(value: unknown): Record { return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record) diff --git a/packages/happy-agent-modules/sources/happy/normalizeHappyToolCall.ts b/packages/happy-agent-modules/sources/happy/normalizeHappyToolCall.ts new file mode 100644 index 00000000..06531d8d --- /dev/null +++ b/packages/happy-agent-modules/sources/happy/normalizeHappyToolCall.ts @@ -0,0 +1,611 @@ +import { Type } from "@sinclair/typebox"; +import { Value } from "@sinclair/typebox/value"; +import { posix } from "node:path"; + +import { + parseCodexPatch, + type CodexPatchHunk, + type CodexPatchOperation, +} from "../impl/parseCodexPatch.js"; + +const nonEmptyString = Type.String({ minLength: 1 }); + +const applyPatchArgumentsSchema = Type.Object( + { + patch: nonEmptyString, + workdir: Type.Optional(Type.String()), + }, + { additionalProperties: true }, +); + +const execCommandArgumentsSchema = Type.Object( + { + cmd: nonEmptyString, + workdir: Type.Optional(Type.String()), + }, + { additionalProperties: true }, +); + +const commandArgumentsSchema = Type.Object( + { command: nonEmptyString }, + { additionalProperties: true }, +); + +const readFileArgumentsSchema = Type.Object( + { target_file: nonEmptyString }, + { additionalProperties: true }, +); + +const viewImageArgumentsSchema = Type.Object( + { path: nonEmptyString }, + { additionalProperties: true }, +); + +const listDirectoryArgumentsSchema = Type.Object( + { target_directory: nonEmptyString }, + { additionalProperties: true }, +); + +const writeArgumentsSchema = Type.Object( + { + content: Type.String(), + file_path: nonEmptyString, + }, + { additionalProperties: true }, +); + +const editArgumentsSchema = Type.Object( + { + file_path: nonEmptyString, + new_string: Type.String(), + old_string: Type.String(), + replace_all: Type.Optional(Type.Boolean()), + }, + { additionalProperties: true }, +); + +const queryArgumentsSchema = Type.Object( + { + query: nonEmptyString, + allowed_domains: Type.Optional(Type.Array(Type.String())), + blocked_domains: Type.Optional(Type.Array(Type.String())), + domains: Type.Optional(Type.Array(Type.String())), + include_domains: Type.Optional(Type.Array(Type.String())), + latest: Type.Optional(Type.Boolean()), + }, + { additionalProperties: true }, +); + +const workflowArgumentsSchema = Type.Object( + { + input: Type.Object( + { + description: Type.Optional(nonEmptyString), + name: Type.Optional(nonEmptyString), + scriptPath: Type.Optional(nonEmptyString), + }, + { additionalProperties: true }, + ), + }, + { additionalProperties: true }, +); + +const displayArgumentsSchema = Type.Object( + { + agent_id: Type.Optional(nonEmptyString), + ask_id: Type.Optional(nonEmptyString), + at: Type.Optional(Type.Union([Type.String(), Type.Number()])), + bash_id: Type.Optional(nonEmptyString), + chars: Type.Optional(Type.String()), + command: Type.Optional(Type.String()), + description: Type.Optional(Type.String()), + id: Type.Optional(nonEmptyString), + input: Type.Optional(Type.Unknown()), + name: Type.Optional(Type.String()), + objective: Type.Optional(Type.String()), + path: Type.Optional(Type.String()), + pattern: Type.Optional(Type.String()), + presenceId: Type.Optional(Type.String()), + query: Type.Optional(Type.String()), + requestId: Type.Optional(nonEmptyString), + scheduleId: Type.Optional(nonEmptyString), + secretId: Type.Optional(nonEmptyString), + server: Type.Optional(nonEmptyString), + session_id: Type.Optional(Type.Number()), + status: Type.Optional(Type.String()), + targetAgentId: Type.Optional(nonEmptyString), + target: Type.Optional(nonEmptyString), + task_id: Type.Optional(nonEmptyString), + task_ids: Type.Optional(Type.Array(nonEmptyString)), + title: Type.Optional(Type.String()), + toAgentId: Type.Optional(nonEmptyString), + tool: Type.Optional(Type.String()), + url: Type.Optional(Type.String()), + workspaceId: Type.Optional(nonEmptyString), + }, + { additionalProperties: true }, +); + +const nestedDisplayArgumentsSchema = Type.Object( + { + agent_id: Type.Optional(nonEmptyString), + ask_id: Type.Optional(nonEmptyString), + id: Type.Optional(nonEmptyString), + presenceId: Type.Optional(Type.String()), + requestId: Type.Optional(nonEmptyString), + status: Type.Optional(Type.String()), + }, + { additionalProperties: true }, +); + +const changesArgumentsSchema = Type.Object( + { changes: Type.Record(Type.String(), Type.Unknown()) }, + { additionalProperties: true }, +); + +const filePathArgumentsSchema = Type.Object( + { file_path: nonEmptyString }, + { additionalProperties: true }, +); + +const pathArgumentsSchema = Type.Object({ path: nonEmptyString }, { additionalProperties: true }); + +const patternArgumentsSchema = Type.Object( + { pattern: nonEmptyString }, + { additionalProperties: true }, +); + +const urlArgumentsSchema = Type.Object({ url: nonEmptyString }, { additionalProperties: true }); + +const webSearchDisplayArgumentsSchema = Type.Object( + { + query: nonEmptyString, + source: Type.Optional(Type.Literal("x")), + }, + { additionalProperties: true }, +); + +const SEARCH_TOOL_NAMES = new Set([ + "bedrock_web_search", + "claude_web_search", + "codex_web_search", + "gemini_web_search", + "grok_web_search", + "grok_x_search", +]); + +export interface NormalizedHappyToolCall { + readonly args: Record; + readonly name: string; +} + +export interface HappyToolCallPresentation { + readonly description: string; + readonly title: string; +} + +/** + * Translate only when Happy mobile already has a renderer for the same operation and payload. + * An unfamiliar tool is still a canonical session tool call; preserving its real name is more + * useful than forcing it into a client shape that means something else. + */ +export function normalizeHappyToolCall( + name: string, + args: Record, +): NormalizedHappyToolCall { + if (name === "apply_patch" && Value.Check(applyPatchArgumentsSchema, args)) { + try { + const operations = parseCodexPatch(args.patch); + return { + name: "CodexPatch", + args: { changes: mobilePatchChanges(operations, args.workdir) }, + }; + } catch { + return { name, args }; + } + } + + if (name === "exec_command" && Value.Check(execCommandArgumentsSchema, args)) { + const { cmd, workdir, ...rest } = args; + return { + name: "CodexBash", + args: { + ...rest, + command: cmd, + ...(workdir === undefined ? {} : { cwd: workdir }), + }, + }; + } + + if (name === "run_terminal_command" && Value.Check(commandArgumentsSchema, args)) { + return { name: "Bash", args }; + } + + if (name === "read_file" && Value.Check(readFileArgumentsSchema, args)) { + const { target_file, ...rest } = args; + return { name: "Read", args: { ...rest, file_path: target_file } }; + } + + if (name === "view_image" && Value.Check(viewImageArgumentsSchema, args)) { + const { path, ...rest } = args; + return { name: "Read", args: { ...rest, file_path: path } }; + } + + if (name === "write" && Value.Check(writeArgumentsSchema, args)) { + return { name: "Write", args }; + } + if (name === "search_replace" && Value.Check(editArgumentsSchema, args)) { + return { name: "Edit", args }; + } + + if (name === "list_dir" && Value.Check(listDirectoryArgumentsSchema, args)) { + const { target_directory, ...rest } = args; + return { name: "LS", args: { ...rest, path: target_directory } }; + } + + if (name === "grep" && Value.Check(patternArgumentsSchema, args)) { + return { name: "Grep", args }; + } + if (name === "web_fetch" && Value.Check(urlArgumentsSchema, args)) { + return { name: "WebFetch", args }; + } + + if (SEARCH_TOOL_NAMES.has(name) && Value.Check(queryArgumentsSchema, args)) { + const { domains, include_domains, ...rest } = args; + const allowedDomains = args.allowed_domains ?? domains ?? include_domains; + return { + name: "WebSearch", + args: { + ...rest, + ...(allowedDomains === undefined ? {} : { allowed_domains: allowedDomains }), + ...(name === "grok_x_search" ? { source: "x" } : {}), + }, + }; + } + + return { name, args }; +} + +/** The short activity text Happy mobile shows for a canonical session tool call. */ +export function happyToolCallPresentation( + originalName: string, + normalized: NormalizedHappyToolCall, +): HappyToolCallPresentation { + const { args, name } = normalized; + const title = titleForCanonicalTool(name); + const displayArgs = Value.Check(displayArgumentsSchema, args) ? args : undefined; + + if (name === "CodexPatch") { + const fileCount = Value.Check(changesArgumentsSchema, args) + ? Object.keys(args.changes).length + : 0; + return { + title: "Apply patch", + description: + fileCount === 1 + ? "Applying patch to 1 file" + : `Applying patch to ${String(fileCount)} files`, + }; + } + + if (Value.Check(commandArgumentsSchema, args) && (name === "Bash" || name === "CodexBash")) { + const purpose = concise(displayArgs?.description); + return { title, description: purpose ?? `Running ${name}` }; + } + + if (Value.Check(filePathArgumentsSchema, args)) { + if (name === "Read") return { title, description: `Reading ${args.file_path}` }; + if (name === "Edit") return { title, description: `Editing ${args.file_path}` }; + if (name === "Write") return { title, description: `Writing ${args.file_path}` }; + } + + if (name === "LS" && Value.Check(pathArgumentsSchema, args)) { + return { title, description: `Listing ${args.path}` }; + } + if ((name === "Glob" || name === "Grep") && Value.Check(patternArgumentsSchema, args)) { + return { title, description: `Searching for ${concise(args.pattern) ?? args.pattern}` }; + } + if (name === "WebFetch" && Value.Check(urlArgumentsSchema, args)) { + return { title, description: `Fetching ${concise(args.url) ?? args.url}` }; + } + if (name === "WebSearch" && Value.Check(webSearchDisplayArgumentsSchema, args)) { + return { + title, + description: `${args.source === "x" ? "Searching X" : "Searching the web"} for ${concise(args.query) ?? args.query}`, + }; + } + if (originalName === "run_workflow" && Value.Check(workflowArgumentsSchema, args)) { + const workflow = args.input.name ?? args.input.description ?? args.input.scriptPath; + return { + title, + description: + workflow === undefined + ? "Starting a background workflow" + : `Starting workflow ${concise(workflow) ?? workflow}`, + }; + } + + if (displayArgs === undefined) { + return { title, description: `Running ${title}` }; + } + + const exact = exactToolDescription(originalName, displayArgs); + return { title, description: exact ?? `Running ${title}` }; +} + +function exactToolDescription( + name: string, + args: typeof displayArgumentsSchema.static, +): string | undefined { + switch (name) { + case "BashInput": + return args.bash_id === undefined + ? "Sending input to a background shell" + : `Sending input to shell ${args.bash_id}`; + case "BashOutput": + return args.bash_id === undefined + ? "Reading background shell output" + : `Reading output from shell ${args.bash_id}`; + case "BashStop": + return args.bash_id === undefined + ? "Stopping a background shell" + : `Stopping shell ${args.bash_id}`; + case "write_stdin": + return args.session_id === undefined + ? "Sending input to a shell session" + : args.chars === undefined || args.chars.length === 0 + ? `Waiting for shell session ${String(args.session_id)}` + : `Sending input to shell session ${String(args.session_id)}`; + case "kill_session": + return args.session_id === undefined + ? "Stopping a shell session" + : `Stopping shell session ${String(args.session_id)}`; + case "get_command_or_subagent_output": + return args.task_ids === undefined + ? "Reading background command output" + : `Reading output from ${String(args.task_ids.length)} background command${args.task_ids.length === 1 ? "" : "s"}`; + case "send_command_input": + return targetDescription("Sending input to", args.task_id); + case "kill_command_or_subagent": + return targetDescription("Stopping background command", args.task_id); + case "create_agent": + return namedDescription("Starting collaborator", args.title); + case "send_agent_message": + return targetDescription("Sending a message to", args.toAgentId); + case "interrupt_agent": + return targetDescription("Interrupting", args.targetAgentId); + case "read_agent_history": + return targetDescription("Reading history for", args.target ?? args.agent_id); + case "create_goal": + return namedDescription("Creating goal", args.objective); + case "get_goal": + return "Checking the current goal"; + case "update_goal": + return namedDescription("Updating goal", args.status); + case "clear_goal": + return "Clearing the current goal"; + case "create_task": + return namedDescription("Creating task", args.title); + case "list_tasks": + return "Listing tasks"; + case "get_task": + return targetDescription("Reading task", args.id); + case "update_task": + return targetDescription("Updating task", args.id); + case "complete_task": + return targetDescription("Completing task", args.id); + case "remove_task": + return targetDescription("Removing task", args.id); + case "get_usage": + return "Reading token usage"; + case "get_agent_tree_usage": + return "Reading agent-tree usage"; + default: + return workspaceAndServiceDescription(name, args); + } +} + +function workspaceAndServiceDescription( + name: string, + args: typeof displayArgumentsSchema.static, +): string | undefined { + const nested = Value.Check(nestedDisplayArgumentsSchema, args.input) ? args.input : undefined; + switch (name) { + case "list_projects": + return "Listing projects"; + case "create_child_workspace": + case "create_workspace": + return namedDescription("Creating workspace", args.name); + case "list_workspaces": + return "Listing workspaces"; + case "get_workspace": + return targetDescription("Reading workspace", args.workspaceId); + case "rename_workspace": + return namedDescription("Renaming workspace", args.name); + case "archive_workspace": + return targetDescription("Archiving workspace", args.workspaceId); + case "get_workspace_branch_metadata": + return targetDescription("Reading branch metadata for", args.workspaceId); + case "list_secrets": + return "Listing available secret references"; + case "reference_secret": + return targetDescription("Reading secret reference", args.id); + case "attach_secret": + return targetDescription("Attaching secret", args.secretId); + case "detach_secret": + return targetDescription("Detaching secret", args.secretId); + case "list_workflows": + return "Listing workflows"; + case "workflow_status": + return targetDescription("Checking workflow", args.id); + case "cancel_workflow": + return targetDescription("Stopping workflow", args.id); + case "resume_workflow": + return targetDescription("Resuming workflow", args.id); + case "wait_workflow": + return targetDescription("Waiting for workflow", args.id); + case "workflow_logs": + return targetDescription("Reading workflow logs for", args.id ?? nested?.id); + case "wait": + return "Waiting"; + case "wait_until": + return namedDescription("Waiting until", formatScalar(args.at)); + case "schedule_message": + return targetDescription("Scheduling a message for", args.agent_id ?? nested?.agent_id); + case "list_scheduled_messages": + return "Listing scheduled messages"; + case "cancel_scheduled_message": + return targetDescription("Cancelling scheduled message", args.scheduleId); + case "request_user_input": + return "Waiting for your answer"; + case "cancel_ask": + return targetDescription( + "Cancelling question", + args.requestId ?? args.ask_id ?? nested?.requestId ?? nested?.ask_id, + ); + case "get_presence": + return "Checking your presence"; + case "list_presences": + return "Listing presence states"; + case "set_presence": + return namedDescription( + "Setting presence", + args.presenceId ?? args.status ?? nested?.presenceId ?? nested?.status, + ); + case "list_skills": + return "Listing skills"; + case "read_skill": + return namedDescription("Reading skill", args.name); + case "list_mcp_servers": + return "Listing MCP servers"; + case "list_mcp_tools": + return targetDescription("Listing MCP tools from", args.server); + case "call_mcp_tool": + return namedDescription("Calling MCP tool", args.name ?? args.tool); + case "list_mcp_resources": + return targetDescription("Listing MCP resources from", args.server); + case "list_mcp_resource_templates": + return targetDescription("Listing MCP resource templates from", args.server); + case "read_mcp_resource": + return targetDescription("Reading MCP resource from", args.server); + case "list_mcp_prompts": + return targetDescription("Listing MCP prompts from", args.server); + case "get_mcp_prompt": + return namedDescription("Reading MCP prompt", args.name); + case "codex_imagegen": + case "gemini_imagegen": + return "Generating an image"; + case "gemini_generate_music": + return "Generating music"; + case "gemini_analyze_media": + return "Analyzing media"; + default: + return undefined; + } +} + +function targetDescription(action: string, target: string | undefined): string { + const value = concise(target); + return value === undefined ? action : `${action} ${value}`; +} + +function namedDescription(action: string, value: string | undefined): string { + const text = concise(value); + return text === undefined ? action : `${action}: ${text}`; +} + +function concise(value: string | undefined): string | undefined { + if (value === undefined) return undefined; + const collapsed = value.trim().replaceAll(/\s+/gu, " "); + if (collapsed.length === 0) return undefined; + return collapsed.length <= 120 ? collapsed : `${collapsed.slice(0, 119)}…`; +} + +function formatScalar(value: string | number | undefined): string | undefined { + return value === undefined ? undefined : String(value); +} + +function titleForCanonicalTool(name: string): string { + switch (name) { + case "Bash": + case "CodexBash": + return "Terminal"; + case "LS": + return "List Files"; + case "WebFetch": + return "Fetch URL"; + case "WebSearch": + return "Web Search"; + default: + return humanizeToolName(name); + } +} + +function humanizeToolName(value: string): string { + const spaced = value + .replaceAll(/[_-]+/gu, " ") + .replaceAll(/([a-z])([A-Z])/gu, "$1 $2") + .trim(); + return spaced.length === 0 + ? "Tool" + : spaced + .split(/\s+/u) + .map((part) => part[0]!.toUpperCase() + part.slice(1)) + .join(" "); +} + +function mobilePatchChanges( + operations: readonly CodexPatchOperation[], + workdir: string | undefined, +): Record { + const changes: Record = {}; + for (const operation of operations) { + const path = mobilePatchPath(operation.path, workdir); + if (operation.kind === "add") { + changes[path] = { + add: { content: operation.lines.join("\n") }, + kind: { move_path: null, type: "add" }, + }; + continue; + } + if (operation.kind === "delete") { + changes[path] = { kind: { move_path: null, type: "delete" } }; + continue; + } + changes[path] = { + kind: { + move_path: + operation.moveTo === undefined + ? null + : mobilePatchPath(operation.moveTo, workdir), + type: "update", + }, + modify: mobilePatchContentPair(operation.hunks), + }; + } + return changes; +} + +function mobilePatchContentPair(hunks: readonly CodexPatchHunk[]): { + readonly old_content: string; + readonly new_content: string; +} { + const oldLines: string[] = []; + const newLines: string[] = []; + for (const hunk of hunks) { + if (hunk.anchor !== undefined) { + oldLines.push(hunk.anchor); + newLines.push(hunk.anchor); + } + for (const line of hunk.lines) { + if (line.marker !== "+") oldLines.push(line.text); + if (line.marker !== "-") newLines.push(line.text); + } + } + return { old_content: oldLines.join("\n"), new_content: newLines.join("\n") }; +} + +function mobilePatchPath(path: string, workdir: string | undefined): string { + if (!workdir || workdir === "." || posix.isAbsolute(path)) return path; + return posix.normalize(posix.join(workdir, path)); +} diff --git a/packages/happy-agent-modules/sources/compute/tools/codex/impl/parseCodexPatch.ts b/packages/happy-agent-modules/sources/impl/parseCodexPatch.ts similarity index 100% rename from packages/happy-agent-modules/sources/compute/tools/codex/impl/parseCodexPatch.ts rename to packages/happy-agent-modules/sources/impl/parseCodexPatch.ts diff --git a/packages/happy-agent-modules/tests/compute/impl/computeContract.edge.test.ts b/packages/happy-agent-modules/tests/compute/impl/computeContract.edge.test.ts index 35d2afa1..36a6ad76 100644 --- a/packages/happy-agent-modules/tests/compute/impl/computeContract.edge.test.ts +++ b/packages/happy-agent-modules/tests/compute/impl/computeContract.edge.test.ts @@ -11,7 +11,7 @@ import { searchComputeFileContents } from "../../../sources/compute/impl/searchC import { startComputeCommand } from "../../../sources/compute/impl/startComputeCommand.js"; import { walkComputeFiles } from "../../../sources/compute/impl/walkComputeFiles.js"; import { writeComputeCommandInput } from "../../../sources/compute/impl/writeComputeCommandInput.js"; -import { parseCodexPatch } from "../../../sources/compute/tools/codex/impl/parseCodexPatch.js"; +import { parseCodexPatch } from "../../../sources/impl/parseCodexPatch.js"; import { parseClaudeBashId } from "../../../sources/compute/tools/claude/impl/parseClaudeBashId.js"; import { parseGrokTaskId } from "../../../sources/compute/tools/grok/impl/parseGrokTaskId.js"; import { FakeCompute } from "../support/FakeCompute.js"; diff --git a/packages/happy-agent-modules/tests/happy/backfillHappyHistory.test.ts b/packages/happy-agent-modules/tests/happy/backfillHappyHistory.test.ts index 366a7340..a110781e 100644 --- a/packages/happy-agent-modules/tests/happy/backfillHappyHistory.test.ts +++ b/packages/happy-agent-modules/tests/happy/backfillHappyHistory.test.ts @@ -147,7 +147,7 @@ describe("mapping archived Happy history", () => { blocks: [ { text: "checking the config", type: "text" }, { - arguments: { path: "/etc/hosts" }, + arguments: { file_path: "/etc/hosts" }, callId: "call-1", name: "Read", type: "tool_call", @@ -173,9 +173,9 @@ describe("mapping archived Happy history", () => { }, { ev: { - args: { path: "/etc/hosts" }, + args: { file_path: "/etc/hosts" }, call: "call-1", - description: "Running Read", + description: "Reading /etc/hosts", name: "Read", t: "tool-call-start", title: "Read", @@ -186,7 +186,7 @@ describe("mapping archived Happy history", () => { turn: "history:tool-message", }, { - ev: { call: "call-1", t: "tool-call-end" }, + ev: { call: "call-1", result: "Read complete", t: "tool-call-end" }, id: "history:tool-message:2", role: "agent", time: 1_000, @@ -195,6 +195,46 @@ describe("mapping archived Happy history", () => { ]); }); + it("uses the same Codex patch wire shape for archived apply_patch calls", () => { + const queued = mapHistory([ + historyMessage({ + recordId: "patch-message", + blocks: [ + { + arguments: { + patch: [ + "*** Begin Patch", + "*** Add File: note.txt", + "+hello", + "*** End Patch", + ].join("\n"), + }, + callId: "patch-call", + name: "apply_patch", + type: "tool_call", + }, + ], + role: "assistant", + }), + ]); + + expect(shown(queued)[0]?.ev).toEqual({ + args: { + changes: { + "note.txt": { + add: { content: "hello" }, + kind: { move_path: null, type: "add" }, + }, + }, + }, + call: "patch-call", + description: "Applying patch to 1 file", + name: "CodexPatch", + t: "tool-call-start", + title: "Apply patch", + }); + }); + it("leaves private reasoning out without disturbing the visible message identity", () => { const queued = mapHistory([ historyMessage({ diff --git a/packages/happy-agent-modules/tests/happy/mapHappyMessages.test.ts b/packages/happy-agent-modules/tests/happy/mapHappyMessages.test.ts index 72e37368..82fb6819 100644 --- a/packages/happy-agent-modules/tests/happy/mapHappyMessages.test.ts +++ b/packages/happy-agent-modules/tests/happy/mapHappyMessages.test.ts @@ -151,7 +151,7 @@ describe("Happy message mapping", () => { expect(spoken).toEqual([]); }); - it("names a tool the way a person reads it", () => { + it("keeps an unfamiliar tool in the canonical generic fallback", () => { const mapper = new HappyMessageMapper(); mapper.map(blockStart()); const started = mapper.map( @@ -160,7 +160,7 @@ describe("Happy message mapping", () => { toolCall: { arguments: { path: "README.md" }, id: "call-1", - name: "read_file", + name: "custom_tool", type: "toolCall", }, type: "tool_execution_start", @@ -171,10 +171,10 @@ describe("Happy message mapping", () => { expect(started[0]?.content.ev).toEqual({ args: { path: "README.md" }, call: "call-1", - description: "Running Read File", - name: "read_file", + description: "Running Custom Tool", + name: "custom_tool", t: "tool-call-start", - title: "Read File", + title: "Custom Tool", }); const finished = mapper.map( @@ -187,7 +187,189 @@ describe("Happy message mapping", () => { runId: RUN, }), ); - expect(finished[0]?.content.ev).toEqual({ call: "call-1", t: "tool-call-end" }); + expect(finished[0]?.content.ev).toEqual({ + call: "call-1", + result: "ok", + t: "tool-call-end", + }); + }); + + it("presents Happy Agent coordination tools in the terms a person needs", () => { + const mapper = new HappyMessageMapper(); + mapper.map(blockStart()); + + const calls = [ + { + arguments: { input: { name: "Security review", script: "{'ok': True}" } }, + id: "workflow-1", + name: "run_workflow", + }, + { + arguments: { text: "Check the authentication path.", toAgentId: "agent42" }, + id: "message-1", + name: "send_agent_message", + }, + { + arguments: { targetAgentId: "agent42" }, + id: "interrupt-1", + name: "interrupt_agent", + }, + ]; + + const presented = calls.map( + (toolCall) => + mapper.map( + event("tool.started", { + rigEvent: { + toolCall: { ...toolCall, type: "toolCall" }, + type: "tool_execution_start", + }, + runId: RUN, + }), + )[0]?.content.ev, + ); + + expect(presented).toEqual([ + { + args: calls[0]?.arguments, + call: "workflow-1", + description: "Starting workflow Security review", + name: "run_workflow", + t: "tool-call-start", + title: "Run Workflow", + }, + { + args: calls[1]?.arguments, + call: "message-1", + description: "Sending a message to agent42", + name: "send_agent_message", + t: "tool-call-start", + title: "Send Agent Message", + }, + { + args: calls[2]?.arguments, + call: "interrupt-1", + description: "Interrupting agent42", + name: "interrupt_agent", + t: "tool-call-start", + title: "Interrupt Agent", + }, + ]); + }); + + it("normalizes apply_patch into Happy's Codex patch tool shape", () => { + const mapper = new HappyMessageMapper(); + mapper.map(blockStart()); + const patch = [ + "*** Begin Patch", + "*** Add File: sources/new.ts", + "+export const answer = 42;", + "*** Update File: sources/old.ts", + "*** Move to: sources/moved.ts", + "@@ export function answer()", + "- return 41;", + "+ return 42;", + "*** Delete File: sources/unused.ts", + "*** End Patch", + ].join("\n"); + + const started = mapper.map( + event("tool.started", { + rigEvent: { + toolCall: { + arguments: { patch, workdir: "packages/mobile" }, + id: "patch-1", + name: "apply_patch", + type: "toolCall", + }, + type: "tool_execution_start", + }, + runId: RUN, + }), + ); + + expect(started[0]?.content.ev).toEqual({ + args: { + changes: { + "packages/mobile/sources/old.ts": { + kind: { + move_path: "packages/mobile/sources/moved.ts", + type: "update", + }, + modify: { + old_content: ["export function answer()", " return 41;"].join("\n"), + new_content: ["export function answer()", " return 42;"].join("\n"), + }, + }, + "packages/mobile/sources/new.ts": { + add: { content: "export const answer = 42;" }, + kind: { move_path: null, type: "add" }, + }, + "packages/mobile/sources/unused.ts": { + kind: { move_path: null, type: "delete" }, + }, + }, + }, + call: "patch-1", + description: "Applying patch to 3 files", + name: "CodexPatch", + t: "tool-call-start", + title: "Apply patch", + }); + }); + + it("keeps malformed apply_patch calls on Happy's generic tool fallback", () => { + const mapper = new HappyMessageMapper(); + mapper.map(blockStart()); + + const started = mapper.map( + event("tool.started", { + rigEvent: { + toolCall: { + arguments: { patch: "not a Codex patch" }, + id: "patch-bad", + name: "apply_patch", + type: "toolCall", + }, + type: "tool_execution_start", + }, + runId: RUN, + }), + ); + + expect(started[0]?.content.ev).toMatchObject({ + args: { patch: "not a Codex patch" }, + name: "apply_patch", + title: "Apply Patch", + }); + }); + + it("tells Happy when a tool failed and what it reported", () => { + const mapper = new HappyMessageMapper(); + mapper.map(blockStart()); + + const finished = mapper.map( + event("tool.completed", { + callId: "call-failed", + rigEvent: { + result: { + display: "The collaborator could not be interrupted.", + isError: true, + toolCallId: "call-failed", + type: "tool_result", + }, + type: "tool_execution_end", + }, + runId: RUN, + }), + ); + + expect(finished[0]?.content.ev).toEqual({ + call: "call-failed", + isError: true, + result: "The collaborator could not be interrupted.", + t: "tool-call-end", + }); }); it("reports a tool the provider ran on its own side", () => { diff --git a/packages/happy-agent-modules/tests/happy/normalizeHappyToolCall.test.ts b/packages/happy-agent-modules/tests/happy/normalizeHappyToolCall.test.ts new file mode 100644 index 00000000..09d98121 --- /dev/null +++ b/packages/happy-agent-modules/tests/happy/normalizeHappyToolCall.test.ts @@ -0,0 +1,228 @@ +import { describe, expect, it } from "vitest"; + +import { + happyToolCallPresentation, + normalizeHappyToolCall, +} from "../../sources/happy/normalizeHappyToolCall.js"; + +describe("Happy mobile tool-call normalization", () => { + it.each([ + { + source: "exec_command", + input: { cmd: "pnpm test", workdir: "packages/app", yield_time_ms: 1_000 }, + name: "CodexBash", + args: { command: "pnpm test", cwd: "packages/app", yield_time_ms: 1_000 }, + }, + { + source: "run_terminal_command", + input: { command: "git status", description: "Checking the worktree" }, + name: "Bash", + args: { command: "git status", description: "Checking the worktree" }, + }, + { + source: "read_file", + input: { target_file: "src/app.ts", offset: 4, limit: 20 }, + name: "Read", + args: { file_path: "src/app.ts", offset: 4, limit: 20 }, + }, + { + source: "view_image", + input: { path: "art/result.png", detail: "original" }, + name: "Read", + args: { file_path: "art/result.png", detail: "original" }, + }, + { + source: "write", + input: { file_path: "src/app.ts", content: "hello" }, + name: "Write", + args: { file_path: "src/app.ts", content: "hello" }, + }, + { + source: "search_replace", + input: { file_path: "src/app.ts", old_string: "a", new_string: "b" }, + name: "Edit", + args: { file_path: "src/app.ts", old_string: "a", new_string: "b" }, + }, + { + source: "list_dir", + input: { target_directory: "src" }, + name: "LS", + args: { path: "src" }, + }, + { + source: "grep", + input: { pattern: "tool-call", path: "src" }, + name: "Grep", + args: { pattern: "tool-call", path: "src" }, + }, + { + source: "web_fetch", + input: { url: "https://happy.engineering", maxCharacters: 4_000 }, + name: "WebFetch", + args: { url: "https://happy.engineering", maxCharacters: 4_000 }, + }, + { + source: "claude_web_search", + input: { query: "Happy", allowed_domains: ["happy.engineering"] }, + name: "WebSearch", + args: { query: "Happy", allowed_domains: ["happy.engineering"] }, + }, + { + source: "codex_web_search", + input: { query: "Happy", domains: ["happy.engineering"] }, + name: "WebSearch", + args: { query: "Happy", allowed_domains: ["happy.engineering"] }, + }, + { + source: "grok_web_search", + input: { query: "Happy", include_domains: ["happy.engineering"] }, + name: "WebSearch", + args: { query: "Happy", allowed_domains: ["happy.engineering"] }, + }, + { + source: "grok_x_search", + input: { query: "Happy", latest: true }, + name: "WebSearch", + args: { query: "Happy", latest: true, source: "x" }, + }, + { + source: "bedrock_web_search", + input: { query: "Happy" }, + name: "WebSearch", + args: { query: "Happy" }, + }, + { + source: "gemini_web_search", + input: { query: "Happy" }, + name: "WebSearch", + args: { query: "Happy" }, + }, + ])("maps $source to the existing $name client contract", ({ source, input, name, args }) => { + expect(normalizeHappyToolCall(source, input)).toEqual({ name, args }); + }); + + it("sends Codex updates through mobile's paired Claude-style diff contract", () => { + const patch = [ + "*** Begin Patch", + "*** Update File: src/math.ts", + "@@ export function answer()", + "- return 41;", + "+ return 42;", + "@@ export function question()", + "- return 'unknown';", + "+ return 'known';", + "*** End Patch", + ].join("\n"); + + expect(normalizeHappyToolCall("apply_patch", { patch })).toEqual({ + name: "CodexPatch", + args: { + changes: { + "src/math.ts": { + kind: { move_path: null, type: "update" }, + modify: { + old_content: [ + "export function answer()", + " return 41;", + "export function question()", + " return 'unknown';", + ].join("\n"), + new_content: [ + "export function answer()", + " return 42;", + "export function question()", + " return 'known';", + ].join("\n"), + }, + }, + }, + }, + }); + }); + + it.each([ + ["Bash", { command: "pnpm test" }], + ["Read", { file_path: "README.md" }], + ["Edit", { file_path: "README.md", old_string: "a", new_string: "b" }], + ["Write", { file_path: "README.md", content: "hello" }], + ["Glob", { pattern: "**/*.ts" }], + ["Grep", { pattern: "hello" }], + ["request_user_input", { question: "Ship it?" }], + ["mcp__linear__create_issue", { title: "Bug" }], + ])("preserves already canonical %s calls", (name, args) => { + expect(normalizeHappyToolCall(name, args)).toEqual({ name, args }); + }); + + it.each([ + ["exec_command", { workdir: "." }], + ["read_file", { path: "README.md" }], + ["write", { file_path: "README.md" }], + ["search_replace", { file_path: "README.md", old_string: "a" }], + ["list_dir", { path: "." }], + ["grep", { path: "." }], + ["web_fetch", { maxCharacters: 1_000 }], + ["codex_web_search", { domains: ["example.com"] }], + ])("keeps malformed %s calls intact for the generic renderer", (name, args) => { + expect(normalizeHappyToolCall(name, args)).toEqual({ name, args }); + }); + + it.each([ + ["BashInput", { bash_id: "bash-7", input: "yes\n" }, "Sending input to shell bash-7"], + ["BashOutput", { bash_id: "bash-7" }, "Reading output from shell bash-7"], + ["BashStop", { bash_id: "bash-7" }, "Stopping shell bash-7"], + ["write_stdin", { session_id: 7 }, "Waiting for shell session 7"], + ["kill_session", { session_id: 7 }, "Stopping shell session 7"], + ["create_agent", { title: "Review auth" }, "Starting collaborator: Review auth"], + ["send_agent_message", { toAgentId: "agent42" }, "Sending a message to agent42"], + ["interrupt_agent", { targetAgentId: "agent42" }, "Interrupting agent42"], + ["create_task", { title: "Fix sync" }, "Creating task: Fix sync"], + ["complete_task", { id: "task-7" }, "Completing task task-7"], + ["create_workspace", { name: "Patch sync" }, "Creating workspace: Patch sync"], + ["workflow_status", { id: "workflow-7" }, "Checking workflow workflow-7"], + [ + "workflow_logs", + { input: { id: "workflow-7", from: "end" } }, + "Reading workflow logs for workflow-7", + ], + [ + "schedule_message", + { input: { agent_id: "agent42", message: "Check it", in: "10m" } }, + "Scheduling a message for agent42", + ], + ["set_presence", { input: { status: "away", message: "Lunch" } }, "Setting presence: away"], + ["read_skill", { name: "sessions" }, "Reading skill: sessions"], + [ + "call_mcp_tool", + { server: "linear", name: "create_issue" }, + "Calling MCP tool: create_issue", + ], + ])("gives generic %s rows exact user-visible context", (name, args, description) => { + const normalized = normalizeHappyToolCall(name, args); + expect(happyToolCallPresentation(name, normalized).description).toBe(description); + }); + + it.each([ + ["exec_command", { cmd: "pnpm test" }, "CodexBash", "Running CodexBash"], + [ + "run_terminal_command", + { command: "git status", description: "Checking Git" }, + "Bash", + "Checking Git", + ], + ["read_file", { target_file: "src/app.ts" }, "Read", "Reading src/app.ts"], + [ + "search_replace", + { file_path: "src/app.ts", old_string: "a", new_string: "b" }, + "Edit", + "Editing src/app.ts", + ], + ["grok_x_search", { query: "Happy" }, "WebSearch", "Searching X for Happy"], + ])( + "describes normalized %s as the client-native activity", + (source, args, name, description) => { + const normalized = normalizeHappyToolCall(source, args); + expect(normalized.name).toBe(name); + expect(happyToolCallPresentation(source, normalized).description).toBe(description); + }, + ); +});