From f36e8bb375cf4410c74cb7a982b4faae71cee186 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 29 Jun 2026 19:46:58 -0400 Subject: [PATCH 1/2] Fix ADE chat CLI launch flows --- apps/ade-cli/README.md | 3 + apps/ade-cli/src/cli.test.ts | 194 ++++++++++++++++-- apps/ade-cli/src/cli.ts | 156 +++++++++++++- .../ade-cli-control-plane/SKILL.md | 21 ++ .../main/services/adeActions/registry.test.ts | 42 +++- .../src/main/services/adeActions/registry.ts | 39 ++++ docs/ARCHITECTURE.md | 2 +- docs/features/agents/README.md | 2 +- docs/features/chat/README.md | 16 +- 9 files changed, 447 insertions(+), 28 deletions(-) diff --git a/apps/ade-cli/README.md b/apps/ade-cli/README.md index 76b0ff6b1..9f9a66e7d 100644 --- a/apps/ade-cli/README.md +++ b/apps/ade-cli/README.md @@ -292,10 +292,13 @@ ade run defs --text ade run start web --lane lane-id ade shell start --lane lane-id -- npm test ade shell start-cli codex --lane lane-id --permission-mode edit --message "fix failing tests" +ade shell start-cli claude --lane lane-id --model anthropic/claude-opus-4-8 --reasoning-effort ultracode --prompt "fix failing tests" ade shell start-cli --provider claude --lane lane-id --permission-mode default ade chat list --lane lane-id --include-automation --no-archived --text ade chat create --lane lane-id --provider codex --model openai/gpt-5.5 --reasoning-effort xhigh --no-fast --permissions full-auto +ade chat create --lane lane-id --provider claude --model anthropic/claude-opus-4-8 --prompt "fix failing tests" ade chat create --lane lane-id --provider codex --model openai/gpt-5.5 --permissions full-auto --print-config --json +ade chat read session-id --limit 20 --text ade agent spawn --lane lane-id --provider codex --model openai/gpt-5.5 --permissions full-auto --prompt "fix failing tests" ade code ade code --embedded diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index 5cb5d83e6..cc3b700f0 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -86,6 +86,16 @@ function expectExecutePlan( return plan; } +function expectStaticPlan( + plan: ReturnType, +): Extract, { kind: "static" }> { + expect(plan.kind).toBe("static"); + if (plan.kind !== "static") { + throw new Error(`Expected static plan, got ${plan.kind}`); + } + return plan; +} + function writeSyncHostSingletonLock(args: { lockPath: string; pid: number; @@ -718,10 +728,9 @@ describe("ADE CLI", () => { ]); const plan = buildCliPlan(parsed.command); - expect(plan.kind).toBe("execute"); - if (plan.kind !== "execute") return; + const executePlan = expectExecutePlan(plan); - expect(plan.steps[0]?.params).toEqual({ + expect(executePlan.steps[0]?.params).toEqual({ name: "run_ade_action", arguments: { domain: "file", @@ -927,10 +936,9 @@ describe("ADE CLI", () => { "--arg-json", 'metadata.tags=["review"]', ]); - expect(plan.kind).toBe("execute"); - if (plan.kind !== "execute") return; + const executePlan = expectExecutePlan(plan); - expect(plan.steps[0]?.params).toEqual({ + expect(executePlan.steps[0]?.params).toEqual({ name: "run_ade_action", arguments: { domain: "git", @@ -1065,10 +1073,9 @@ describe("ADE CLI", () => { "openInUi=true", ]); - expect(plan.kind).toBe("execute"); - if (plan.kind !== "execute") return; + const executePlan = expectExecutePlan(plan); - expect(plan.steps[0]?.params).toEqual({ + expect(executePlan.steps[0]?.params).toEqual({ name: "run_ade_action", arguments: { domain: "chat", @@ -1091,6 +1098,40 @@ describe("ADE CLI", () => { }); }); + it("chains chat create --prompt into a first chat send", () => { + const plan = buildCliPlan([ + "chat", + "create", + "--lane", + "lane-1", + "--provider", + "claude", + "--model", + "anthropic/claude-opus-4-8", + "--prompt", + "Fix the tests", + ]); + + const executePlan = expectExecutePlan(plan); + expect(executePlan.steps).toHaveLength(2); + expect(executePlan.steps[0]?.key).toBe("session"); + + const sendStep = executePlan.steps[1]!; + const sendParams = (sendStep.params as (v: Record) => Record)({ + session: { domain: "chat", action: "createSession", result: { sessionId: "chat-new" } }, + }); + expect(sendParams).toMatchObject({ + arguments: { + domain: "chat", + action: "sendMessage", + args: { + sessionId: "chat-new", + text: "Fix the tests", + }, + }, + }); + }); + it("prints chat create config without launching a session", () => { const plan = buildCliPlan([ "chat", @@ -1109,10 +1150,9 @@ describe("ADE CLI", () => { "--print-config", ]); - expect(plan.kind).toBe("static"); - if (plan.kind !== "static") return; - const value = plan.value as { input: Record }; - expect(plan.value).toMatchObject({ + const staticPlan = expectStaticPlan(plan); + const value = staticPlan.value as { input: Record }; + expect(staticPlan.value).toMatchObject({ ok: true, dryRun: true, action: "chat.createSession", @@ -1142,6 +1182,41 @@ describe("ADE CLI", () => { expect(value.input).not.toHaveProperty("title"); }); + it("prints chat create --prompt dry-run with the follow-up send", () => { + const plan = buildCliPlan([ + "chat", + "create", + "--lane", + "lane-1", + "--provider", + "claude", + "--model", + "anthropic/claude-opus-4-8", + "--prompt", + "Fix the tests", + "--print-config", + ]); + + const staticPlan = expectStaticPlan(plan); + expect(staticPlan.value).toMatchObject({ + action: "chat.createSession", + input: { + laneId: "lane-1", + provider: "claude", + model: "anthropic/claude-opus-4-8", + }, + afterCreate: [ + { + action: "chat.sendMessage", + input: { + sessionId: "", + text: "Fix the tests", + }, + }, + ], + }); + }); + it("omits unset optional fields from chat create config previews", () => { const plan = buildCliPlan([ "chat", @@ -1155,10 +1230,9 @@ describe("ADE CLI", () => { "--print-config", ]); - expect(plan.kind).toBe("static"); - if (plan.kind !== "static") return; - const value = plan.value as { input: Record }; - expect(plan.value).toMatchObject({ + const staticPlan = expectStaticPlan(plan); + const value = staticPlan.value as { input: Record }; + expect(staticPlan.value).toMatchObject({ input: { laneId: "lane-1", provider: "codex", @@ -1182,6 +1256,32 @@ describe("ADE CLI", () => { expect(value.input).not.toHaveProperty("codexFastMode"); }); + it("builds chat read as a transcript action", () => { + const plan = buildCliPlan([ + "chat", + "read", + "chat-1", + "--limit", + "25", + "--since", + "2026-06-29T00:00:00.000Z", + ]); + + const executePlan = expectExecutePlan(plan); + expect(executePlan.formatter).toBe("chat-read"); + expect(executePlan.steps[0]?.params).toMatchObject({ + arguments: { + domain: "chat", + action: "readTranscript", + args: { + sessionId: "chat-1", + limit: 25, + since: "2026-06-29T00:00:00.000Z", + }, + }, + }); + }); + it("rejects reasoning effort on legacy agent spawn", () => { expect(() => buildCliPlan([ @@ -1197,6 +1297,20 @@ describe("ADE CLI", () => { ).toThrow(/agent spawn does not support reasoning effort/); }); + it("rejects conflicting plain chat create prompt and no-kickoff flags", () => { + expect(() => + buildCliPlan([ + "chat", + "create", + "--lane", + "lane-1", + "--prompt", + "Fix the tests", + "--no-kickoff", + ]), + ).toThrow(/--no-kickoff cannot be used with --prompt/); + }); + it("rejects --print=value on chat send", () => { expect(() => buildCliPlan([ "chat", @@ -1208,6 +1322,22 @@ describe("ADE CLI", () => { ])).toThrow(/--print must be set at session creation time/); }); + it("formats chat transcript reads as role-separated text", () => { + const text = formatOutput( + [ + { role: "user", text: "hello", timestamp: "2026-06-29T12:00:00.000Z" }, + { role: "assistant", text: "hi", timestamp: "2026-06-29T12:00:01.000Z" }, + ], + { ...baseResolveOpts(), projectRoot: null, workspaceRoot: null, text: true }, + "chat-read", + ); + + expect(text).toContain("ADE chat transcript"); + expect(text).toContain("user 2026-06-29T12:00:00.000Z"); + expect(text).toContain("hello"); + expect(text).toContain("assistant 2026-06-29T12:00:01.000Z"); + }); + it("builds chat show/status as positional session summary calls", () => { const show = buildCliPlan(["chat", "show", "chat-1"]); expect(show.kind).toBe("execute"); @@ -1380,6 +1510,28 @@ describe("ADE CLI", () => { action: "getStatus", result: { clean: true }, }); + + const chatCreateWithKickoff = summarizeExecution({ + plan: { kind: "execute", label: "chat create", steps: [] }, + connection, + values: { + session: { + domain: "chat", + action: "createSession", + result: { sessionId: "chat-new" }, + }, + result: { + domain: "chat", + action: "sendMessage", + result: { ok: true, accepted: true, sessionId: "chat-new" }, + }, + }, + } as any); + expect(chatCreateWithKickoff).toEqual({ + ok: true, + session: { sessionId: "chat-new" }, + kickoff: { ok: true, accepted: true, sessionId: "chat-new" }, + }); }); @@ -2440,8 +2592,16 @@ describe("ADE CLI", () => { expect(chatCreateHelp.kind).toBe("help"); if (chatCreateHelp.kind !== "help") return; expect(chatCreateHelp.text).toContain("--reasoning-effort"); + expect(chatCreateHelp.text).toContain("ultracode"); + expect(chatCreateHelp.text).toContain("--prompt "); + expect(chatCreateHelp.text).toContain("ade shell start-cli claude"); expect(chatCreateHelp.text).toContain("codexSandbox=danger-full-access"); + const chatHelp = buildCliPlan(["help", "chat"]); + expect(chatHelp.kind).toBe("help"); + if (chatHelp.kind !== "help") return; + expect(chatHelp.text).toContain("ade chat read "); + const agentSpawnHelp = buildCliPlan(["agent", "spawn", "--help"]); expect(agentSpawnHelp.kind).toBe("help"); if (agentSpawnHelp.kind !== "help") return; diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index f5e147aff..5032823ac 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -148,6 +148,7 @@ type FormatterId = | "run-defs" | "run-runtime" | "chat-list" + | "chat-read" | "tests-runs" | "proof-list" | "ios-sim-status" @@ -1293,6 +1294,7 @@ const HELP_BY_COMMAND: Record = { $ ade shell start --lane -- npm test Start a tracked shell session $ ade shell start --lane -c "npm test" Start with a command string $ ade shell start-cli codex --lane --permission-mode edit + $ ade shell start-cli claude --lane --reasoning-effort ultracode --prompt "fix tests" $ ade shell start --provider claude --lane --message "fix tests" $ ade shell start --lane --chat-session -c "npm test" $ ade shell write --data "q" Write data to a PTY @@ -1301,6 +1303,9 @@ const HELP_BY_COMMAND: Record = { After start, use the returned session id with: $ ade terminal read --terminal --text + + Use start-cli for tracked provider CLI sessions. It supports --reasoning-effort + for reasoning-aware providers; ade agent spawn is the legacy CLI-session path. `, terminal: `${ADE_BANNER} Attached terminal @@ -1341,8 +1346,12 @@ const HELP_BY_COMMAND: Record = { $ ade chat list --lane --text List chat sessions $ ade chat list --include-automation --no-archived --text $ ade chat create --lane --provider codex --model openai/gpt-5.5 --reasoning-effort xhigh --no-fast --permissions full-auto + $ ade chat create --lane --provider claude --model anthropic/claude-opus-4-8 --prompt "fix the tests" $ ade chat create --from-linear-issue ENG-431 Start a chat with an attached issue + kickoff (alias: --linear-issue-json) $ ade chat send --text "next step" Send a message + $ ade chat read --limit 20 --text Read recent chat messages + $ ade shell start-cli claude --lane --reasoning-effort ultracode --prompt "fix" + Start a tracked Claude Code CLI session $ ade chat attach-linear-issue --issue-id ENG-431 Attach a Linear issue to a chat/CLI session $ ade chat detach-linear-issue [--issue-id ENG-431] @@ -1356,7 +1365,8 @@ const HELP_BY_COMMAND: Record = { --provider claude | codex | cursor | droid | opencode. --model Model id, also sent as modelId for runtime parity. --reasoning-effort Reasoning tier when the selected model supports it. - Common tiers: minimal, low, medium, high, xhigh, max. + Common tiers: minimal, low, medium, high, xhigh, max, ultracode. + --prompt Create the chat, then send this as the first message. --permissions Alias for --permission-mode. --permission-mode default | auto | plan | edit | full-auto | config-toml. --fast Request fast service tier when the model advertises it. @@ -1377,6 +1387,7 @@ const HELP_BY_COMMAND: Record = { $ ade chat create --lane --provider codex --model openai/gpt-5.5 --reasoning-effort xhigh --no-fast --permissions full-auto $ ade chat create --lane --provider claude --model anthropic/claude-opus-4-8 --effort high --permissions plan + $ ade chat create --lane --provider claude --model anthropic/claude-opus-4-8 --effort ultracode --prompt "fix" $ ade chat create --lane --provider cursor --model cursor/ --standard --print-config --json $ ade chat create --from-linear-issue ENG-431 --provider codex --model openai/gpt-5.5 --prompt "Work this issue" @@ -1386,6 +1397,9 @@ const HELP_BY_COMMAND: Record = { --model Model id, also sent as modelId for runtime parity. --reasoning-effort Reasoning tier when supported by the model. --effort Alias for --reasoning-effort. + Common tiers: minimal, low, medium, high, xhigh, max, ultracode. + --prompt Create the chat, then send this as the first message. + --kickoff Alias for --prompt. --permissions Alias for --permission-mode. --permission-mode default | auto | plan | edit | full-auto | config-toml. --fast Request fast service tier when supported. @@ -1407,6 +1421,10 @@ const HELP_BY_COMMAND: Record = { Discovery: $ ade actions run chat.modelCatalog --input-json '{"mode":"cached"}' --json $ ade actions run chat.getAvailableModels --input-json '{"provider":"codex"}' --json + + CLI sessions: + Use ade shell start-cli claude ... --reasoning-effort when you want + a tracked Claude Code terminal session instead of a persistent Work chat. `, agent: `${ADE_BANNER} Agent sessions @@ -3616,14 +3634,41 @@ function compactPreviewObject(input: JsonObject): JsonObject { return output; } -function buildChatCreateConfigPreview(args: JsonObject): JsonObject { +function buildChatCreateConfigPreview( + args: JsonObject, + options: { + linearIssue?: JsonObject | null; + kickoffText?: string | null; + noKickoff?: boolean; + } = {}, +): JsonObject { const input = compactPreviewObject(args); const permissionMode = asString(input.permissionMode) ?? "default"; + const afterCreate: JsonObject[] = []; + if (options.linearIssue) { + afterCreate.push({ + action: "lane.attachLinearIssueToSession", + input: compactPreviewObject({ + chatSessionId: "", + issues: [options.linearIssue], + }), + }); + } + if (!options.noKickoff && options.kickoffText) { + afterCreate.push({ + action: "chat.sendMessage", + input: { + sessionId: "", + text: options.kickoffText, + }, + }); + } return { ok: true, dryRun: true, action: "chat.createSession", input, + ...(afterCreate.length ? { afterCreate } : {}), resolved: { provider: asString(input.provider) ?? null, model: asString(input.model) ?? asString(input.modelId) ?? null, @@ -5726,6 +5771,28 @@ function buildChatPlan(args: string[]): CliPlan { ]), ], }; + if (sub === "read" || sub === "messages" || sub === "transcript") { + const targetSession = requireValue(sessionId, "sessionId"); + const limit = readIntOption(args, ["--limit"], 50); + const since = readValue(args, ["--since"]); + return { + kind: "execute", + label: "chat read", + formatter: "chat-read", + steps: [ + actionStep( + "result", + "chat", + "readTranscript", + collectGenericObjectArgs(args, { + sessionId: targetSession, + ...(limit !== undefined ? { limit } : {}), + ...(since ? { since } : {}), + }), + ), + ], + }; + } if ( sub === "attach-linear-issue" || sub === "attach-linear" || @@ -5852,6 +5919,9 @@ function buildChatPlan(args: string[]): CliPlan { } const noKickoff = readFlag(args, ["--no-kickoff"]); const explicitKickoff = readValue(args, ["--prompt", "--kickoff", "--kickoff-prompt"]); + if (!linearIssue && noKickoff && explicitKickoff) { + throw new CliUsageError("--no-kickoff cannot be used with --prompt on plain chat create."); + } const attachmentFlags = linearIssue ? readLinearAttachmentFlags(args) : {}; const createStep = actionStep( "result", @@ -5880,14 +5950,52 @@ function buildChatPlan(args: string[]): CliPlan { ); const createArgs = (createStep.params as JsonObject).arguments as JsonObject; const actionArgs = createArgs.args as JsonObject; + const kickoffText = + explicitKickoff ?? + (linearIssue && !noKickoff ? deriveLinearKickoffPrompt(linearIssue) : null); if (printConfig) { return { kind: "static", - value: buildChatCreateConfigPreview(actionArgs), + value: buildChatCreateConfigPreview(actionArgs, { + linearIssue, + kickoffText, + noKickoff, + }), formatter: "action-result", }; } if (!linearIssue) { + if (explicitKickoff) { + return { + kind: "execute", + label: "chat create", + steps: [ + { ...createStep, key: "session" }, + { + key: "result", + method: "ade/actions/call", + params: (values) => { + const targetSession = sessionIdFromCreateChatValue(values.session); + if (!targetSession) { + throw new CliUsageError("chat create could not resolve the new session id to send the prompt."); + } + return { + name: "run_ade_action", + arguments: { + domain: "chat", + action: "sendMessage", + args: { + sessionId: targetSession, + text: explicitKickoff, + }, + }, + }; + }, + unwrapToolResult: true, + }, + ], + }; + } return { kind: "execute", label: "chat create", steps: [createStep] }; } const issueForKickoff = linearIssue; @@ -14017,6 +14125,23 @@ function formatChatList(value: unknown): string { ); } +function formatChatRead(value: unknown): string { + const entries = Array.isArray(value) + ? value.filter(isRecord) + : firstArray(value, ["entries", "messages", "items"]); + if (!entries.length) return "ADE chat transcript\n(no messages)"; + const lines = ["ADE chat transcript"]; + for (const entry of entries) { + const role = asString(entry.role) ?? "message"; + const timestamp = asString(entry.timestamp); + const text = asString(entry.displayText) ?? asString(entry.text) ?? ""; + lines.push(""); + lines.push(timestamp ? `${role} ${timestamp}` : role); + lines.push(text.length ? text : "(empty)"); + } + return lines.join("\n"); +} + function formatTestsRuns(value: unknown): string { const runs = firstArray(value, ["runs", "items"]); return renderTable( @@ -14984,6 +15109,8 @@ function formatTextOutput( return formatRunTable(value, "ADE process runtime"); case "chat-list": return formatChatList(value); + case "chat-read": + return formatChatRead(value); case "tests-runs": return formatTestsRuns(value); case "proof-list": @@ -15217,6 +15344,29 @@ function summarizeExecution(args: { return summarizePrCreateResult(values.result ?? values); } + if ( + (plan.label === "chat create" || + plan.label === "chat create from Linear issue") && + values.session !== undefined + ) { + return { + ok: true, + session: unwrapActionEnvelope(values.session), + ...(values.attach !== undefined ? { attach: unwrapActionEnvelope(values.attach) } : {}), + ...(values.result !== undefined ? { kickoff: unwrapActionEnvelope(values.result) } : {}), + }; + } + + if (plan.label === "chat send") { + const raw = unwrapActionEnvelope(values.result); + if (isRecord(raw)) return raw; + return { + ok: true, + accepted: true, + note: "Message accepted by the ADE chat service; provider dispatch continues asynchronously.", + }; + } + if ( plan.label === "history list" || plan.label === "history export" || diff --git a/apps/desktop/resources/agent-skills/ade-cli-control-plane/SKILL.md b/apps/desktop/resources/agent-skills/ade-cli-control-plane/SKILL.md index f3d6d55b9..fefec7c44 100644 --- a/apps/desktop/resources/agent-skills/ade-cli-control-plane/SKILL.md +++ b/apps/desktop/resources/agent-skills/ade-cli-control-plane/SKILL.md @@ -81,6 +81,27 @@ ade lanes create-from-linear --issue-id ENG-431 --start-chat --provider codex -- ade chat create --from-linear-issue ENG-431 # chat with the issue attached + kickoff ``` +## Chat vs. CLI sessions + +Use `ade chat create` for persistent ADE Work chats. `--prompt` creates the +chat and then sends that text as the first message; use `--print-config` / +`--dry-run` to verify both the create payload and the follow-up send before +launching. Use `ade chat send --text ...` for later messages and +`ade chat read --text` to confirm recent transcript messages. + +Use `ade shell start-cli ` for tracked provider CLI sessions in a +terminal. This is the reasoning-aware CLI path, for example: + +``` +ade shell start-cli claude --lane --model anthropic/claude-opus-4-8 --reasoning-effort ultracode --permissions full-auto --prompt "Fix the issue" +``` + +`ade agent spawn` is the older CLI-session launcher and rejects +`--reasoning-effort`; choose it only when you do not need to pin a reasoning +tier. Common reasoning tiers include `minimal`, `low`, `medium`, `high`, +`xhigh`, `max`, and `ultracode`; confirm model-specific support with +`ade actions run chat.modelCatalog --json`. + Report what you actually did back to the issue with `ade linear comment` as you progress — that comment is how reviewers and the issue's watchers see status. Use `ade help linear` for the full flag set. ## Fallback path diff --git a/apps/desktop/src/main/services/adeActions/registry.test.ts b/apps/desktop/src/main/services/adeActions/registry.test.ts index 293813d34..587e95d8b 100644 --- a/apps/desktop/src/main/services/adeActions/registry.test.ts +++ b/apps/desktop/src/main/services/adeActions/registry.test.ts @@ -80,7 +80,9 @@ describe("isAllowedAdeAction", () => { it("exposes subagent transcript reads through the chat runtime action surface", () => { expect(isAllowedAdeAction("chat", "getSubagentTranscript")).toBe(true); + expect(isAllowedAdeAction("chat", "readTranscript")).toBe(true); expect(isCtoOnlyAdeAction("chat", "getSubagentTranscript")).toBe(false); + expect(isCtoOnlyAdeAction("chat", "readTranscript")).toBe(false); }); it("exposes Codex goal actions and getCommit through the runtime action surface", () => { @@ -350,17 +352,29 @@ describe("ADE_ACTION_ALLOWLIST shape", () => { expect(getAdeActionInputContract("chat", "getSessionSummary")).toMatchObject({ input: expect.stringContaining("scalar sessionId"), }); + expect(getAdeActionInputContract("chat", "readTranscript")).toMatchObject({ + input: expect.stringContaining("limit"), + }); + expect(getAdeActionInputContract("chat", "sendMessage")).toMatchObject({ + description: expect.stringContaining("asynchronously"), + }); }); - it("normalizes chat action argument shapes for model discovery and session summaries", async () => { + it("normalizes chat action argument shapes for model discovery, summaries, transcript reads, and sends", async () => { const createSession = vi.fn(async (args?: unknown) => ({ sessionId: "chat-new", args })); const getAvailableModels = vi.fn(async (args: { provider?: string }) => [{ id: args.provider ?? "any" }]); const getSessionSummary = vi.fn(async (sessionId: string) => ({ sessionId })); + const readTranscript = vi.fn(async (sessionId: string, limit?: number, since?: string) => ([ + { role: "user", text: sessionId, timestamp: since ?? "now", limit }, + ])); + const sendMessage = vi.fn(async () => undefined); const runtime = { agentChatService: { createSession, getAvailableModels, getSessionSummary, + readTranscript, + sendMessage, }, } as unknown as Parameters[0]; @@ -368,6 +382,8 @@ describe("ADE_ACTION_ALLOWLIST shape", () => { createSession?: (args?: unknown) => Promise; getAvailableModels?: (args?: unknown) => Promise; getSessionSummary?: (args?: unknown) => Promise; + readTranscript?: (args?: unknown) => Promise; + sendMessage?: (args?: unknown) => Promise; }; await expect(chat.getAvailableModels?.({})).resolves.toEqual([{ id: "any" }]); @@ -395,6 +411,30 @@ describe("ADE_ACTION_ALLOWLIST shape", () => { provider: "codex", reasoningEffort: "xhigh", }); + + await expect(chat.readTranscript?.({ + sessionId: " chat-1 ", + limit: "25", + since: "2026-06-29T00:00:00.000Z", + })).resolves.toEqual([ + { + role: "user", + text: "chat-1", + timestamp: "2026-06-29T00:00:00.000Z", + limit: 25, + }, + ]); + expect(readTranscript).toHaveBeenCalledWith("chat-1", 25, "2026-06-29T00:00:00.000Z"); + + await expect(chat.sendMessage?.({ + sessionId: " chat-1 ", + text: "next", + })).resolves.toMatchObject({ + ok: true, + accepted: true, + sessionId: "chat-1", + }); + expect(sendMessage).toHaveBeenCalledWith({ sessionId: "chat-1", text: "next" }); }); it("unwraps chat.listSessions action args before calling the positional chat service API", async () => { diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index 083448c31..f2592d94d 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -465,6 +465,7 @@ export const ADE_ACTION_ALLOWLIST: Partial { + const record = readObjectActionArg(args, "chat.readTranscript"); + const sessionId = requireNonEmptyString(record.sessionId, "sessionId"); + const limitValue = record.limit; + const parsedLimit = typeof limitValue === "number" + ? limitValue + : typeof limitValue === "string" && limitValue.trim() + ? Number.parseInt(limitValue, 10) + : undefined; + const limit = typeof parsedLimit === "number" && Number.isFinite(parsedLimit) + ? Math.max(1, Math.min(500, Math.floor(parsedLimit))) + : undefined; + const since = typeof record.since === "string" && record.since.trim() + ? record.since.trim() + : undefined; + return agentChatService.readTranscript(sessionId, limit, since); + }, + sendMessage: async (args?: unknown) => { + const record = readObjectActionArg(args, "chat.sendMessage"); + const sessionId = requireNonEmptyString(record.sessionId, "sessionId"); + await agentChatService.sendMessage({ ...record, sessionId } as never); + return { + ok: true, + accepted: true, + sessionId, + note: "Message accepted by the ADE chat service; provider dispatch continues asynchronously.", + }; + }, setParallelLaunchState: (args?: AgentChatSetParallelLaunchStateArgs) => { const parentLaneId = requireNonEmptyString(args?.parentLaneId, "parentLaneId"); const key = agentChatParallelLaunchStateKey(runtime.projectRoot, parentLaneId); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b3d2658c3..12d03a221 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -131,7 +131,7 @@ Product positioning and workflows live in [`docs/PRD.md`](../docs/PRD.md). This **Session identity.** The runtime resolves caller role from ADE context env vars and command flags. Role vocabulary: `cto`, `orchestrator`, `agent`, `external`, `evaluator`. -**Action surface.** First-class command families cover lanes (including `ade lanes link-linear-issue` / `detach-linear-issue` for post-creation Linear issue linking, and `ade lanes create-from-linear` / `batch-create-from-linear` to spin up one or many issue lanes — optionally launching an agent chat with `--start-chat`), git, diffs, files, PRs, runs, shells, chats (including `ade chat create --from-linear-issue `, `ade chat attach-linear-issue` / `detach-linear-issue` / `linear-issues` for session-scoped issue attachment), agents, CTO, Linear (the write bridge an attached CLI agent uses: `ade linear attach` / `detach` / `issues` / `issue` / `comment` / `set-state` / `assign` / `label`, with `--this-session` resolving the issue id from `$ADE_LINEAR_ISSUE_IDS` so a launched agent needs no Linear token — see [features/linear-integration/README.md](./features/linear-integration/README.md#session-scoped-issue-attachment-and-cli-context-injection)), tests, proof, settings, the iOS Simulator (`ade ios-sim` / `ade ios` / `ade simulator` — see [features/ios-simulator/README.md](./features/ios-simulator/README.md)), the Cursor Cloud bridge (`ade cursor cloud agents | runs | artifacts | repos | models | me` — talks directly to `@cursor/sdk` without going through the ADE runtime endpoint), the App Control bridge for Electron apps (`ade app-control` / `ade app` / `ade electron` — `launch`, `connect`, `stop`, `status`, `screenshot`, `snapshot`, `inspect`, `select`, `click`, `type`, `scroll`, `key`, `targets`, `attach`, `logs`, `terminal write`, `terminal signal` — see [features/computer-use/app-control.md](./features/computer-use/app-control.md)), the chat-scoped terminal (`ade terminal list` / `read` / `write` / `signal` / `active`), and a generic `ade actions run ` escape hatch for every registered ADE service action. The action allow-list adds three domains for these surfaces: `app_control` (every public method on `AppControlService`), `terminal` (`list`, `read`, `write`, `signal`, `activeForChat` against `ptyService`), and named iOS Simulator actions for launch, live view, inspection, input, and Preview Lab workflows. +**Action surface.** First-class command families cover lanes (including `ade lanes link-linear-issue` / `detach-linear-issue` for post-creation Linear issue linking, and `ade lanes create-from-linear` / `batch-create-from-linear` to spin up one or many issue lanes — optionally launching an agent chat with `--start-chat`), git, diffs, files, PRs, runs, shells, chats (including `ade chat create --prompt` for a persistent Work chat followed by an initial `chat.sendMessage`, `ade chat read ` for recent transcript messages, `ade chat create --from-linear-issue `, and `ade chat attach-linear-issue` / `detach-linear-issue` / `linear-issues` for session-scoped issue attachment), agents, CTO, Linear (the write bridge an attached CLI agent uses: `ade linear attach` / `detach` / `issues` / `issue` / `comment` / `set-state` / `assign` / `label`, with `--this-session` resolving the issue id from `$ADE_LINEAR_ISSUE_IDS` so a launched agent needs no Linear token — see [features/linear-integration/README.md](./features/linear-integration/README.md#session-scoped-issue-attachment-and-cli-context-injection)), tests, proof, settings, the iOS Simulator (`ade ios-sim` / `ade ios` / `ade simulator` — see [features/ios-simulator/README.md](./features/ios-simulator/README.md)), the Cursor Cloud bridge (`ade cursor cloud agents | runs | artifacts | repos | models | me` — talks directly to `@cursor/sdk` without going through the ADE runtime endpoint), the App Control bridge for Electron apps (`ade app-control` / `ade app` / `ade electron` — `launch`, `connect`, `stop`, `status`, `screenshot`, `snapshot`, `inspect`, `select`, `click`, `type`, `scroll`, `key`, `targets`, `attach`, `logs`, `terminal write`, `terminal signal` — see [features/computer-use/app-control.md](./features/computer-use/app-control.md)), the chat-scoped terminal (`ade terminal list` / `read` / `write` / `signal` / `active`), and a generic `ade actions run ` escape hatch for every registered ADE service action. The chat action surface includes `chat.createSession`, `chat.sendMessage` (returns an accepted acknowledgement while provider dispatch continues asynchronously), `chat.readTranscript`, and model-catalog actions. The action allow-list adds three domains for these surfaces: `app_control` (every public method on `AppControlService`), `terminal` (`list`, `read`, `write`, `signal`, `activeForChat` against `ptyService`), and named iOS Simulator actions for launch, live view, inspection, input, and Preview Lab workflows. **Proof subcommands** — `ade proof capture` (alias of `screenshot`), `ade proof attach `, `ade proof record`, `ade proof launch`, `ade proof interact`, `ade proof list/status/environment/ingest`. `attach` infers the artifact kind from the file extension and routes through `ingest_computer_use_artifacts` with `backendStyle: "manual"`. Capture-style commands set `preferHeadless: true` on the plan so the connection layer drops to headless mode unless `--socket` is explicitly requested. All proof subcommands accept `--owner-kind` / `--owner-id` (with `chat` and `pr` aliases) to layer an explicit owner on top of the inferred session identity. diff --git a/docs/features/agents/README.md b/docs/features/agents/README.md index 30e2ffd77..5b51e1b3c 100644 --- a/docs/features/agents/README.md +++ b/docs/features/agents/README.md @@ -18,7 +18,7 @@ those surfaces. | `apps/desktop/src/main/services/cto/workerAdapterRuntimeService.ts` | Adapter lifecycle for supported worker adapter types. | | `apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts` | CTO-only tools for chat spawning, worker management, and Linear dispatch. | | `apps/desktop/src/main/services/agentTools/agentToolsService.ts` | Detects external CLI tools on PATH. | -| `apps/ade-cli/src/cli.ts` | Agent-focused `ade` command surface and text/JSON output formatters. Includes the `ade ios-sim` (alias `ade ios`, `ade simulator`) family — see [iOS Simulator feature](../ios-simulator/README.md), the `ade --socket app-control ...` driver for live Electron apps, and the `ade --socket browser ...` driver for the in-app browser. The browser CLI covers tabs/navigation (`browser panel`, `open`, `new-tab`, `switch`, `close`), agent sessions (`browser session start`, `browser sessions`, `browser session `, `--browser-session `), hidden-tab observations/actions (`observe --map`, `click/fill/clear-field/press/wait --handle`, `trace`, `proof`), and selection / inspect commands. `ade secrets list|get|set|delete` is the typed surface for encrypted project-scoped ADE secrets that agents may read when the user names a secret. `ade chat create --provider codex --model --reasoning-effort --no-fast --permissions full-auto` starts a persistent Work chat with explicit provider settings; `--print-config`/`--dry-run` prints the resolved create payload and provider permission mapping without launching. `ade agent spawn` remains the legacy lane-scoped CLI-session launcher for Codex/Claude and deliberately rejects reasoning-effort flags; use `ade chat create` or `ade shell start-cli ... --reasoning-effort ` when a launch must pin reasoning. `ade shell start --lane --chat-session ` (or `ADE_CHAT_SESSION_ID` from the env) attaches a tracked shell to an existing chat so `ade --socket terminal read --chat-session "$ADE_CHAT_SESSION_ID" --text` resolves to it. `ade lanes link-linear-issue --linear-issue-json '{...}'` (aliases `link-linear`, `linear-link`) links one or more Linear issues to an existing lane with optional `--role`, `--source`, `--include-in-pr`/`--no-include-in-pr`, and `--close-on-merge` flags. | +| `apps/ade-cli/src/cli.ts` | Agent-focused `ade` command surface and text/JSON output formatters. Includes the `ade ios-sim` (alias `ade ios`, `ade simulator`) family — see [iOS Simulator feature](../ios-simulator/README.md), the `ade --socket app-control ...` driver for live Electron apps, and the `ade --socket browser ...` driver for the in-app browser. The browser CLI covers tabs/navigation (`browser panel`, `open`, `new-tab`, `switch`, `close`), agent sessions (`browser session start`, `browser sessions`, `browser session `, `--browser-session `), hidden-tab observations/actions (`observe --map`, `click/fill/clear-field/press/wait --handle`, `trace`, `proof`), and selection / inspect commands. `ade secrets list|get|set|delete` is the typed surface for encrypted project-scoped ADE secrets that agents may read when the user names a secret. `ade chat create --provider codex --model --reasoning-effort --no-fast --permissions full-auto --prompt "..."` starts a persistent Work chat with explicit provider settings and then sends the prompt as the first message; `--print-config`/`--dry-run` prints the resolved create payload, provider permission mapping, and any follow-up send before launching. `ade chat read --text` reads recent transcript messages after create/send. `ade agent spawn` remains the legacy lane-scoped CLI-session launcher for Codex/Claude and deliberately rejects reasoning-effort flags; use `ade chat create` for Work chats or `ade shell start-cli ... --reasoning-effort ` for tracked CLI sessions when a launch must pin reasoning. `ade shell start --lane --chat-session ` (or `ADE_CHAT_SESSION_ID` from the env) attaches a tracked shell to an existing chat so `ade --socket terminal read --chat-session "$ADE_CHAT_SESSION_ID" --text` resolves to it. `ade lanes link-linear-issue --linear-issue-json '{...}'` (aliases `link-linear`, `linear-link`) links one or more Linear issues to an existing lane with optional `--role`, `--source`, `--include-in-pr`/`--no-include-in-pr`, and `--close-on-merge` flags. | | `apps/ade-cli/src/adeRpcServer.ts` | Private ADE action RPC: registers actions, handles JSON-RPC, applies session-identity-based filtering, builds lane-scoped ADE guidance / `ADE_AGENT_SKILLS_DIRS` for worker CLI launches, and returns GitHub + ADE PR URLs from PR creation tools when available. | | `apps/desktop/src/main/services/cli/adeCliService.ts` | Desktop-side install / status / uninstall surface for the `ade` launcher. Owns the install-target path resolution and the optional shell-rc PATH append. | | `apps/desktop/src/shared/adeCliGuidance.ts` | Canonical agent-prompt guidance builder for finding and using `ade`, reading Agent Skills on demand, naming the bundled ADE skills, using socket-backed live surfaces, registering proof, and cleaning up started processes. Injected into Work chats, CLI launches, ADE Code/TUI sessions, CTO/worker agents, and mobile-started runtime work. | diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md index 49759a146..557295dbd 100644 --- a/docs/features/chat/README.md +++ b/docs/features/chat/README.md @@ -345,11 +345,17 @@ See the detail docs for the specifics: does not complete within that window the stale runtime is discarded and recreated on the next turn. 3. `sendMessage({ sessionId, text, attachments? })` via - `ade.agentChat.send` dispatches a turn. Interactive chat sends are not - wall-clock bounded by the service; the turn runs until the provider - completes or the user/app interrupts it. The blocking `runSessionTurn` - helper used by automation has a 5 min default RPC timeout unless the - caller passes `timeoutMs: null`; background/headless chat launches opt out. + `ade.agentChat.send` dispatches a turn. The ADE action bridge exposes + the same path as `chat.sendMessage`; `ade chat send` returns an accepted + acknowledgement after the service accepts the message, while provider + dispatch and event streaming continue asynchronously. `ade chat create + --prompt` uses this same follow-up send after the session is created, and + `ade chat read ` calls `chat.readTranscript` to inspect recent + transcript messages. Interactive chat sends are not wall-clock bounded by + the service; the turn runs until the provider completes or the user/app + interrupts it. The blocking `runSessionTurn` helper used by automation has + a 5 min default RPC timeout unless the caller passes `timeoutMs: null`; + background/headless chat launches opt out. 4. The runtime streams events through the main-process event emitter and into the renderer via `ade.agentChat.event` (a push channel owned by `registerIpc.ts`). From be3c55445d902adfef54955fda6ad7b3d73e995f Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 29 Jun 2026 20:09:51 -0400 Subject: [PATCH 2/2] Address chat CLI review feedback --- apps/ade-cli/src/cli.test.ts | 176 +++++++++++++++--- apps/ade-cli/src/cli.ts | 9 +- .../main/services/adeActions/registry.test.ts | 37 ++++ .../src/main/services/adeActions/registry.ts | 33 +++- 4 files changed, 218 insertions(+), 37 deletions(-) diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index cc3b700f0..701c4c940 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -1217,6 +1217,48 @@ describe("ADE CLI", () => { }); }); + it("prints chat create from Linear dry-run with attachment flags", () => { + const plan = buildCliPlan([ + "chat", + "create", + "--lane", + "lane-1", + "--from-linear-issue", + "ENG-431", + "--role", + "worked", + "--source", + "chat_attach", + "--include-in-pr", + "--close-on-merge", + "--print-config", + ]); + + const staticPlan = expectStaticPlan(plan); + expect(staticPlan.value).toMatchObject({ + action: "chat.createSession", + afterCreate: [ + { + action: "lane.attachLinearIssueToSession", + input: { + chatSessionId: "", + issues: [{ identifier: "ENG-431" }], + role: "worked", + source: "chat_attach", + includeInPr: true, + closeOnMerge: true, + }, + }, + { + action: "chat.sendMessage", + input: { + sessionId: "", + }, + }, + ], + }); + }); + it("omits unset optional fields from chat create config previews", () => { const plan = buildCliPlan([ "chat", @@ -1297,18 +1339,33 @@ describe("ADE CLI", () => { ).toThrow(/agent spawn does not support reasoning effort/); }); - it("rejects conflicting plain chat create prompt and no-kickoff flags", () => { - expect(() => - buildCliPlan([ - "chat", - "create", - "--lane", - "lane-1", - "--prompt", - "Fix the tests", - "--no-kickoff", - ]), - ).toThrow(/--no-kickoff cannot be used with --prompt/); + it("rejects conflicting chat create kickoff aliases and no-kickoff flags", () => { + for (const kickoffFlag of ["--prompt", "--kickoff", "--kickoff-prompt"]) { + expect(() => + buildCliPlan([ + "chat", + "create", + "--lane", + "lane-1", + kickoffFlag, + "Fix the tests", + "--no-kickoff", + ]), + ).toThrow(/--no-kickoff cannot be used with --prompt\/--kickoff/); + expect(() => + buildCliPlan([ + "chat", + "create", + "--lane", + "lane-1", + "--from-linear-issue", + "ENG-431", + kickoffFlag, + "Fix the tests", + "--no-kickoff", + ]), + ).toThrow(/--no-kickoff cannot be used with --prompt\/--kickoff/); + } }); it("rejects --print=value on chat send", () => { @@ -1511,27 +1568,43 @@ describe("ADE CLI", () => { result: { clean: true }, }); - const chatCreateWithKickoff = summarizeExecution({ - plan: { kind: "execute", label: "chat create", steps: [] }, - connection, - values: { - session: { - domain: "chat", - action: "createSession", - result: { sessionId: "chat-new" }, + for (const mode of ["headless", "desktop-socket"] as const) { + const chatConnection = { ...connection, mode }; + const chatCreateWithKickoff = summarizeExecution({ + plan: { kind: "execute", label: "chat create", steps: [] }, + connection: chatConnection, + values: { + session: { + domain: "chat", + action: "createSession", + result: { sessionId: "chat-new" }, + }, + result: { + domain: "chat", + action: "sendMessage", + result: { ok: true, accepted: true, sessionId: "chat-new" }, + }, }, - result: { - domain: "chat", - action: "sendMessage", - result: { ok: true, accepted: true, sessionId: "chat-new" }, + } as any); + expect(chatCreateWithKickoff).toEqual({ + ok: true, + session: { sessionId: "chat-new" }, + kickoff: { ok: true, accepted: true, sessionId: "chat-new" }, + }); + + const chatRead = summarizeExecution({ + plan: { kind: "execute", label: "chat read", steps: [] }, + connection: chatConnection, + values: { + result: { + domain: "chat", + action: "readTranscript", + result: { entries: [{ role: "user", text: mode }] }, + }, }, - }, - } as any); - expect(chatCreateWithKickoff).toEqual({ - ok: true, - session: { sessionId: "chat-new" }, - kickoff: { ok: true, accepted: true, sessionId: "chat-new" }, - }); + } as any); + expect(chatRead).toEqual({ entries: [{ role: "user", text: mode }] }); + } }); @@ -3067,6 +3140,47 @@ describe("ADE CLI", () => { args: { chatSessionId: "session-x", issues: [{ identifier: "ENG-431" }] }, }, }); + + const noKickoffPlan = expectExecutePlan(buildCliPlan([ + "chat", + "create", + "--lane", + "lane-1", + "--from-linear-issue", + "ENG-431", + "--no-kickoff", + ])); + expect(noKickoffPlan.steps).toHaveLength(2); + expect(noKickoffPlan.steps[1]?.key).toBe("attach"); + + const summary = summarizeExecution({ + plan: noKickoffPlan, + connection: { + mode: "headless", + projectRoot: "/tmp/project", + workspaceRoot: "/tmp/project", + socketPath: "/tmp/project/.ade/ade.sock", + request: async () => null, + close: () => {}, + }, + values: { + session: { + domain: "chat", + action: "createSession", + result: { sessionId: "session-x" }, + }, + attach: { + domain: "lane", + action: "attachLinearIssueToSession", + result: { linked: true }, + }, + }, + } as any); + expect(summary).toEqual({ + ok: true, + session: { sessionId: "session-x" }, + attach: { linked: true }, + }); }); it("routes the linear write-bridge commands to linear_issue_tracker positional actions", () => { diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 5032823ac..da68c44d9 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -3638,6 +3638,7 @@ function buildChatCreateConfigPreview( args: JsonObject, options: { linearIssue?: JsonObject | null; + attachmentFlags?: JsonObject; kickoffText?: string | null; noKickoff?: boolean; } = {}, @@ -3651,6 +3652,7 @@ function buildChatCreateConfigPreview( input: compactPreviewObject({ chatSessionId: "", issues: [options.linearIssue], + ...(options.attachmentFlags ?? {}), }), }); } @@ -5919,8 +5921,8 @@ function buildChatPlan(args: string[]): CliPlan { } const noKickoff = readFlag(args, ["--no-kickoff"]); const explicitKickoff = readValue(args, ["--prompt", "--kickoff", "--kickoff-prompt"]); - if (!linearIssue && noKickoff && explicitKickoff) { - throw new CliUsageError("--no-kickoff cannot be used with --prompt on plain chat create."); + if (noKickoff && explicitKickoff) { + throw new CliUsageError("--no-kickoff cannot be used with --prompt/--kickoff."); } const attachmentFlags = linearIssue ? readLinearAttachmentFlags(args) : {}; const createStep = actionStep( @@ -5958,6 +5960,7 @@ function buildChatPlan(args: string[]): CliPlan { kind: "static", value: buildChatCreateConfigPreview(actionArgs, { linearIssue, + attachmentFlags, kickoffText, noKickoff, }), @@ -6003,7 +6006,7 @@ function buildChatPlan(args: string[]): CliPlan { // First step keyed "session" so attach/kickoff can read the new id. { ...createStep, key: "session" }, { - key: noKickoff ? "result" : "attach", + key: "attach", method: "ade/actions/call", params: (values) => { const targetSession = sessionIdFromCreateChatValue(values.session); diff --git a/apps/desktop/src/main/services/adeActions/registry.test.ts b/apps/desktop/src/main/services/adeActions/registry.test.ts index 587e95d8b..5405ef96c 100644 --- a/apps/desktop/src/main/services/adeActions/registry.test.ts +++ b/apps/desktop/src/main/services/adeActions/registry.test.ts @@ -81,8 +81,10 @@ describe("isAllowedAdeAction", () => { it("exposes subagent transcript reads through the chat runtime action surface", () => { expect(isAllowedAdeAction("chat", "getSubagentTranscript")).toBe(true); expect(isAllowedAdeAction("chat", "readTranscript")).toBe(true); + expect(isAllowedAdeAction("chat", "sendMessage")).toBe(true); expect(isCtoOnlyAdeAction("chat", "getSubagentTranscript")).toBe(false); expect(isCtoOnlyAdeAction("chat", "readTranscript")).toBe(false); + expect(isCtoOnlyAdeAction("chat", "sendMessage")).toBe(false); }); it("exposes Codex goal actions and getCommit through the runtime action surface", () => { @@ -435,6 +437,41 @@ describe("ADE_ACTION_ALLOWLIST shape", () => { sessionId: "chat-1", }); expect(sendMessage).toHaveBeenCalledWith({ sessionId: "chat-1", text: "next" }); + await expect(chat.sendMessage?.({ sessionId: "chat-1", text: " " })).rejects.toThrow(/text/); + expect(sendMessage).toHaveBeenCalledTimes(1); + }); + + it("falls back to headless chat transcript reads when readTranscript is unavailable", async () => { + const getChatTranscript = vi.fn(async () => ({ + sessionId: "chat-1", + entries: [ + { role: "user", text: "old", timestamp: "2026-06-28T00:00:00.000Z" }, + { role: "assistant", text: "new", timestamp: "2026-06-29T00:00:00.000Z" }, + ], + totalEntries: 2, + truncated: false, + })); + const runtime = { + agentChatService: { + getChatTranscript, + }, + } as unknown as Parameters[0]; + + const chat = getAdeActionDomainServices(runtime).chat as { + readTranscript?: (args?: unknown) => Promise; + }; + + await expect(chat.readTranscript?.({ + sessionId: " chat-1 ", + limit: "25", + since: "2026-06-29T00:00:00.000Z", + })).resolves.toMatchObject({ + sessionId: "chat-1", + entries: [ + { role: "assistant", text: "new", timestamp: "2026-06-29T00:00:00.000Z" }, + ], + }); + expect(getChatTranscript).toHaveBeenCalledWith({ sessionId: "chat-1", limit: 25 }); }); it("unwraps chat.listSessions action args before calling the positional chat service API", async () => { diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index f2592d94d..944354b0b 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -1110,7 +1110,7 @@ function buildChatDomainService(runtime: AdeRuntime): OpaqueService | null { Object.keys(options).length ? options : undefined, ); }, - readTranscript: (args?: unknown) => { + readTranscript: async (args?: unknown) => { const record = readObjectActionArg(args, "chat.readTranscript"); const sessionId = requireNonEmptyString(record.sessionId, "sessionId"); const limitValue = record.limit; @@ -1125,12 +1125,39 @@ function buildChatDomainService(runtime: AdeRuntime): OpaqueService | null { const since = typeof record.since === "string" && record.since.trim() ? record.since.trim() : undefined; - return agentChatService.readTranscript(sessionId, limit, since); + const chatService = agentChatService as { + readTranscript?: (sessionId: string, limit?: number, since?: string) => Promise | unknown; + getChatTranscript?: (args: { sessionId: string; limit?: number }) => Promise | unknown; + }; + if (typeof chatService.readTranscript === "function") { + return chatService.readTranscript(sessionId, limit, since); + } + if (typeof chatService.getChatTranscript === "function") { + const transcript = await chatService.getChatTranscript({ + sessionId, + ...(limit !== undefined ? { limit } : {}), + }); + if (!since || !isRecord(transcript) || !Array.isArray(transcript.entries)) { + return transcript; + } + const sinceMs = Date.parse(since); + if (!Number.isFinite(sinceMs)) return transcript; + return { + ...transcript, + entries: transcript.entries.filter((entry) => { + if (!isRecord(entry) || typeof entry.timestamp !== "string") return true; + const timestampMs = Date.parse(entry.timestamp); + return !Number.isFinite(timestampMs) || timestampMs >= sinceMs; + }), + }; + } + throw new Error("Chat transcript reads are not available in this runtime."); }, sendMessage: async (args?: unknown) => { const record = readObjectActionArg(args, "chat.sendMessage"); const sessionId = requireNonEmptyString(record.sessionId, "sessionId"); - await agentChatService.sendMessage({ ...record, sessionId } as never); + const text = requireNonEmptyString(record.text, "text"); + await agentChatService.sendMessage({ ...record, sessionId, text } as never); return { ok: true, accepted: true,