Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 9 additions & 11 deletions src/cli-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
type NormalizedOutputError,
} from "./error-normalization.js";
import { flushPerfMetricsCapture, installPerfMetricsCapture } from "./perf-metrics-capture.js";
import { mergePromptSourceWithText, parsePromptSource, textPrompt } from "./prompt-content.js";
import { runQueueOwnerFromEnv } from "./queue-owner-env.js";
import {
DEFAULT_HISTORY_LIMIT,
Expand Down Expand Up @@ -88,25 +89,22 @@ async function readPrompt(
promptParts: string[],
filePath: string | undefined,
cwd: string,
): Promise<string> {
): Promise<import("./types.js").PromptInput> {
if (filePath) {
const source =
filePath === "-"
? await readPromptInputFromStdin()
: await fs.readFile(path.resolve(cwd, filePath), "utf8");
const pieces = [source.trim(), promptParts.join(" ").trim()].filter(
(value) => value.length > 0,
);
const prompt = pieces.join("\n\n").trim();
if (!prompt) {
const prompt = mergePromptSourceWithText(source, promptParts.join(" "));
if (prompt.length === 0) {
throw new InvalidArgumentError("Prompt from --file is empty");
}
return prompt;
}

const joined = promptParts.join(" ").trim();
if (joined.length > 0) {
return joined;
return textPrompt(joined);
}

if (process.stdin.isTTY) {
Expand All @@ -115,8 +113,8 @@ async function readPrompt(
);
}

const prompt = (await readPromptInputFromStdin()).trim();
if (!prompt) {
const prompt = parsePromptSource(await readPromptInputFromStdin());
if (prompt.length === 0) {
throw new InvalidArgumentError("Prompt from stdin is empty");
}

Expand Down Expand Up @@ -251,7 +249,7 @@ async function handlePrompt(
await printPromptSessionBanner(record, agent.cwd, outputPolicy.format, outputPolicy.jsonStrict);
const result = await sendSession({
sessionId: record.acpxRecordId,
message: prompt,
prompt,
mcpServers: config.mcpServers,
permissionMode,
nonInteractivePermissions: globalFlags.nonInteractivePermissions,
Expand Down Expand Up @@ -327,7 +325,7 @@ async function handleExec(
const result = await runOnce({
agentCommand: agent.agentCommand,
cwd: agent.cwd,
message: prompt,
prompt,
mcpServers: config.mcpServers,
permissionMode,
nonInteractivePermissions: globalFlags.nonInteractivePermissions,
Expand Down
11 changes: 4 additions & 7 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
} from "./errors.js";
import { FileSystemHandlers } from "./filesystem.js";
import { classifyPermissionDecision, resolvePermissionRequest } from "./permissions.js";
import { textPrompt } from "./prompt-content.js";
import { extractRuntimeSessionId } from "./runtime-session-id.js";
import { TimeoutError, withTimeout } from "./session-runtime-helpers.js";
import { TerminalManager } from "./terminal.js";
Expand All @@ -48,6 +49,7 @@ import type {
NonInteractivePermissionPolicy,
PermissionMode,
PermissionStats,
PromptInput,
} from "./types.js";

type CommandParts = {
Expand Down Expand Up @@ -976,7 +978,7 @@ export class AcpClient {
};
}

async prompt(sessionId: string, text: string): Promise<PromptResponse> {
async prompt(sessionId: string, prompt: PromptInput | string): Promise<PromptResponse> {
const connection = this.getConnection();
const restoreConsoleError = this.options.suppressSdkConsoleErrors
? installSdkConsoleErrorSuppression()
Expand All @@ -986,12 +988,7 @@ export class AcpClient {
try {
promptPromise = connection.prompt({
sessionId,
prompt: [
{
type: "text",
text,
},
],
prompt: typeof prompt === "string" ? textPrompt(prompt) : prompt,
});
} catch (error) {
restoreConsoleError?.();
Expand Down
130 changes: 130 additions & 0 deletions src/prompt-content.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import type { ContentBlock } from "@agentclientprotocol/sdk";

export type PromptInput = ContentBlock[];

function asRecord(value: unknown): Record<string, unknown> | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
}
return value as Record<string, unknown>;
}

function isTextBlock(value: unknown): value is Extract<ContentBlock, { type: "text" }> {
const record = asRecord(value);
return record?.type === "text" && typeof record.text === "string";
}

function isImageBlock(value: unknown): value is Extract<ContentBlock, { type: "image" }> {
const record = asRecord(value);
return (
record?.type === "image" &&
typeof record.mimeType === "string" &&
typeof record.data === "string"
);
}

function isResourceLinkBlock(
value: unknown,
): value is Extract<ContentBlock, { type: "resource_link" }> {
const record = asRecord(value);
return (
record?.type === "resource_link" &&
typeof record.uri === "string" &&
(record.title === undefined || typeof record.title === "string") &&
(record.name === undefined || typeof record.name === "string")
);
}

function isResourcePayload(value: unknown): boolean {
const record = asRecord(value);
if (!record || typeof record.uri !== "string") {
return false;
}
return record.text === undefined || typeof record.text === "string";
}

function isResourceBlock(value: unknown): value is Extract<ContentBlock, { type: "resource" }> {
const record = asRecord(value);
return record?.type === "resource" && isResourcePayload(record.resource);
}

function isContentBlock(value: unknown): value is ContentBlock {
return (
isTextBlock(value) ||
isImageBlock(value) ||
isResourceLinkBlock(value) ||
isResourceBlock(value)
);
}

export function isPromptInput(value: unknown): value is PromptInput {
return Array.isArray(value) && value.every((entry) => isContentBlock(entry));
}

export function textPrompt(text: string): PromptInput {
return [
{
type: "text",
text,
},
];
}

function parseStructuredPrompt(source: string): PromptInput | undefined {
if (!source.startsWith("[")) {
return undefined;
}
try {
const parsed = JSON.parse(source) as unknown;
return isPromptInput(parsed) ? parsed : undefined;
} catch {
return undefined;
}
}

export function parsePromptSource(source: string): PromptInput {
const trimmed = source.trim();
const structured = parseStructuredPrompt(trimmed);
if (structured) {
return structured;
}
if (!trimmed) {
return [];
}
return textPrompt(trimmed);
}

export function mergePromptSourceWithText(source: string, suffixText: string): PromptInput {
const prompt = parsePromptSource(source);
const appended = suffixText.trim();
if (!appended) {
return prompt;
}
if (prompt.length === 0) {
return textPrompt(appended);
}
return [...prompt, ...textPrompt(appended)];
}

export function promptToDisplayText(prompt: PromptInput): string {
return prompt
.map((block) => {
switch (block.type) {
case "text":
return block.text;
case "resource_link":
return block.title ?? block.name ?? block.uri;
case "resource":
return "text" in block.resource && typeof block.resource.text === "string"
? block.resource.text
: block.resource.uri;
case "image":
return `[image] ${block.mimeType}`;
default:
return "";
}
})
.filter((entry) => entry.trim().length > 0)
.join("\n\n")
.trim();
}
5 changes: 4 additions & 1 deletion src/queue-ipc-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@ import net from "node:net";
import type { SetSessionConfigOptionResponse } from "@agentclientprotocol/sdk";
import { normalizeOutputError } from "./error-normalization.js";
import { recordPerfDuration } from "./perf-metrics.js";
import { textPrompt } from "./prompt-content.js";
import {
parseQueueRequest,
type QueueOwnerErrorMessage,
type QueueOwnerMessage,
} from "./queue-messages.js";
import type { NonInteractivePermissionPolicy, PermissionMode } from "./types.js";
import type { NonInteractivePermissionPolicy, PermissionMode, PromptInput } from "./types.js";

type QueueOwnerSocketLease = {
socketPath: string;
Expand Down Expand Up @@ -71,6 +72,7 @@ function writeQueueMessage(socket: net.Socket, message: QueueOwnerMessage): void
export type QueueTask = {
requestId: string;
message: string;
prompt: PromptInput;
permissionMode: PermissionMode;
nonInteractivePermissions?: NonInteractivePermissionPolicy;
timeoutMs?: number;
Expand Down Expand Up @@ -440,6 +442,7 @@ export class SessionQueueOwner {
const task: QueueTask = {
requestId: request.requestId,
message: request.message,
prompt: request.prompt ?? textPrompt(request.message),
permissionMode: request.permissionMode,
nonInteractivePermissions: request.nonInteractivePermissions,
timeoutMs: request.timeoutMs,
Expand Down
3 changes: 3 additions & 0 deletions src/queue-ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import type {
OutputErrorEmissionPolicy,
OutputFormatter,
PermissionMode,
PromptInput,
SessionEnqueueResult,
SessionSendOutcome,
} from "./types.js";
Expand Down Expand Up @@ -220,6 +221,7 @@ function assertOwnerGeneration(
export type SubmitToQueueOwnerOptions = {
sessionId: string;
message: string;
prompt?: PromptInput;
permissionMode: PermissionMode;
nonInteractivePermissions?: NonInteractivePermissionPolicy;
outputFormatter: OutputFormatter;
Expand All @@ -246,6 +248,7 @@ async function submitToQueueOwner(
requestId,
ownerGeneration: owner.ownerGeneration,
message: options.message,
prompt: options.prompt,
permissionMode: options.permissionMode,
nonInteractivePermissions: options.nonInteractivePermissions,
timeoutMs: options.timeoutMs,
Expand Down
7 changes: 7 additions & 0 deletions src/queue-messages.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { SetSessionConfigOptionResponse } from "@agentclientprotocol/sdk";
import { isAcpJsonRpcMessage } from "./acp-jsonrpc.js";
import { isPromptInput, textPrompt } from "./prompt-content.js";
import {
OUTPUT_ERROR_CODES,
OUTPUT_ERROR_ORIGINS,
Expand All @@ -11,6 +12,7 @@ import type {
AcpJsonRpcMessage,
NonInteractivePermissionPolicy,
PermissionMode,
PromptInput,
SessionSendResult,
} from "./types.js";

Expand All @@ -19,6 +21,7 @@ export type QueueSubmitRequest = {
requestId: string;
ownerGeneration?: number;
message: string;
prompt?: PromptInput;
permissionMode: PermissionMode;
nonInteractivePermissions?: NonInteractivePermissionPolicy;
timeoutMs?: number;
Expand Down Expand Up @@ -204,9 +207,12 @@ export function parseQueueRequest(raw: unknown): QueueRequest | null {
? request.suppressSdkConsoleErrors
: null;

const prompt =
request.prompt == null ? undefined : isPromptInput(request.prompt) ? request.prompt : null;
if (
typeof request.message !== "string" ||
!isPermissionMode(request.permissionMode) ||
prompt === null ||
nonInteractivePermissions === null ||
suppressSdkConsoleErrors === null ||
typeof request.waitForCompletion !== "boolean"
Expand All @@ -219,6 +225,7 @@ export function parseQueueRequest(raw: unknown): QueueRequest | null {
requestId: request.requestId,
ownerGeneration,
message: request.message,
prompt: prompt ?? textPrompt(request.message),
permissionMode: request.permissionMode,
nonInteractivePermissions,
timeoutMs,
Expand Down
20 changes: 16 additions & 4 deletions src/session-conversation-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ import type {
ToolCallUpdate,
UsageUpdate,
} from "@agentclientprotocol/sdk";
import { textPrompt } from "./prompt-content.js";
import type {
ClientOperation,
PromptInput,
SessionAcpxState,
SessionConversation,
SessionAgentContent,
Expand Down Expand Up @@ -489,18 +491,28 @@ export function appendLegacyHistory(

export function recordPromptSubmission(
conversation: SessionConversation,
prompt: string,
prompt: PromptInput | string,
timestamp = isoNow(),
): void {
const text = prompt.trim();
if (!text) {
const normalizedPrompt = typeof prompt === "string" ? textPrompt(prompt) : prompt;
const userContent = normalizedPrompt
.map((content) => contentToUserContent(content))
.filter((content) => content !== undefined);
if (userContent.length === 0) {
return;
}

conversation.messages.push({
User: {
id: nextUserMessageId(),
content: [{ Text: trimRuntimeText(text, MAX_RUNTIME_AGENT_TEXT_CHARS) }],
content: userContent.map((content) => {
if ("Text" in content) {
return {
Text: trimRuntimeText(content.Text, MAX_RUNTIME_AGENT_TEXT_CHARS),
};
}
return content;
}),
},
});
updateConversationTimestamp(conversation, timestamp);
Expand Down
Loading