Parse Claude JSON output to return text replies
This commit is contained in:
@@ -172,13 +172,43 @@ describe("config and templating", () => {
|
||||
const argv = runSpy.mock.calls[0][0];
|
||||
expect(argv[0]).toBe("claude");
|
||||
expect(argv.at(-1)).toBe("hi");
|
||||
// The helper should auto-add print and output format flags without disturbing the prompt position.
|
||||
expect(argv.includes("-p") || argv.includes("--print")).toBe(true);
|
||||
const outputIdx = argv.findIndex(
|
||||
(part) => part === "--output-format" || part.startsWith("--output-format="),
|
||||
(part) =>
|
||||
part === "--output-format" || part.startsWith("--output-format="),
|
||||
);
|
||||
expect(outputIdx).toBeGreaterThan(-1);
|
||||
expect(argv[outputIdx + 1]).toBe("text");
|
||||
});
|
||||
|
||||
it("parses Claude JSON output and returns text content", async () => {
|
||||
const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({
|
||||
stdout: '{"text":"hello world"}\n',
|
||||
stderr: "",
|
||||
code: 0,
|
||||
signal: null,
|
||||
killed: false,
|
||||
});
|
||||
const cfg = {
|
||||
inbound: {
|
||||
reply: {
|
||||
mode: "command" as const,
|
||||
command: ["claude", "{{Body}}"],
|
||||
claudeOutputFormat: "json" as const,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await index.getReplyFromConfig(
|
||||
{ Body: "hi", From: "+1", To: "+2" },
|
||||
undefined,
|
||||
cfg,
|
||||
runSpy,
|
||||
);
|
||||
|
||||
expect(result).toBe("hello world");
|
||||
});
|
||||
});
|
||||
|
||||
describe("twilio interactions", () => {
|
||||
|
||||
74
src/index.ts
74
src/index.ts
@@ -552,11 +552,69 @@ type TemplateContext = MsgContext & {
|
||||
IsNewSession?: string;
|
||||
};
|
||||
|
||||
function extractClaudeText(payload: unknown): string | undefined {
|
||||
// Best-effort walker to find the primary text field in Claude JSON outputs.
|
||||
if (payload == null) return undefined;
|
||||
if (typeof payload === "string") return payload;
|
||||
if (Array.isArray(payload)) {
|
||||
for (const item of payload) {
|
||||
const found = extractClaudeText(item);
|
||||
if (found) return found;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
if (typeof payload === "object") {
|
||||
const obj = payload as Record<string, unknown>;
|
||||
if (typeof obj.text === "string") return obj.text;
|
||||
if (typeof obj.completion === "string") return obj.completion;
|
||||
if (typeof obj.output === "string") return obj.output;
|
||||
if (obj.message) {
|
||||
const inner = extractClaudeText(obj.message);
|
||||
if (inner) return inner;
|
||||
}
|
||||
if (Array.isArray(obj.messages)) {
|
||||
const inner = extractClaudeText(obj.messages);
|
||||
if (inner) return inner;
|
||||
}
|
||||
if (Array.isArray(obj.content)) {
|
||||
for (const block of obj.content) {
|
||||
if (
|
||||
block &&
|
||||
typeof block === "object" &&
|
||||
(block as { type?: string }).type === "text" &&
|
||||
typeof (block as { text?: unknown }).text === "string"
|
||||
) {
|
||||
return (block as { text: string }).text;
|
||||
}
|
||||
const inner = extractClaudeText(block);
|
||||
if (inner) return inner;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseClaudeJsonText(raw: string): string | undefined {
|
||||
// Handle a single JSON blob or newline-delimited JSON; return the first extracted text.
|
||||
const candidates = [raw, ...raw.split(/\n+/).map((s) => s.trim()).filter(Boolean)];
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const parsed = JSON.parse(candidate);
|
||||
const text = extractClaudeText(parsed);
|
||||
if (text) return text;
|
||||
} catch {
|
||||
// ignore parse errors; try next candidate
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
type SessionEntry = { sessionId: string; updatedAt: number };
|
||||
|
||||
const SESSION_STORE_DEFAULT = path.join(CONFIG_DIR, "sessions.json");
|
||||
const DEFAULT_RESET_TRIGGER = "/new";
|
||||
const DEFAULT_IDLE_MINUTES = 60;
|
||||
const CLAUDE_BIN = "claude";
|
||||
|
||||
function resolveStorePath(store?: string) {
|
||||
if (!store) return SESSION_STORE_DEFAULT;
|
||||
@@ -722,7 +780,7 @@ async function getReplyFromConfig(
|
||||
if (
|
||||
reply.claudeOutputFormat &&
|
||||
argv.length > 0 &&
|
||||
path.basename(argv[0]) === "claude"
|
||||
path.basename(argv[0]) === CLAUDE_BIN
|
||||
) {
|
||||
const hasOutputFormat = argv.some(
|
||||
(part) =>
|
||||
@@ -773,10 +831,22 @@ async function getReplyFromConfig(
|
||||
finalArgv,
|
||||
timeoutMs,
|
||||
);
|
||||
const trimmed = stdout.trim();
|
||||
let trimmed = stdout.trim();
|
||||
if (stderr?.trim()) {
|
||||
logVerbose(`Command auto-reply stderr: ${stderr.trim()}`);
|
||||
}
|
||||
if (reply.claudeOutputFormat === "json" && trimmed) {
|
||||
// Claude JSON mode: extract the human text for both logging and reply.
|
||||
const extracted = parseClaudeJsonText(trimmed);
|
||||
if (extracted) {
|
||||
logVerbose(
|
||||
`Claude JSON parsed -> ${extracted.slice(0, 120)}${extracted.length > 120 ? "…" : ""}`,
|
||||
);
|
||||
trimmed = extracted.trim();
|
||||
} else {
|
||||
logVerbose("Claude JSON parse failed; returning raw stdout");
|
||||
}
|
||||
}
|
||||
logVerbose(
|
||||
`Command auto-reply stdout (trimmed): ${trimmed || "<empty>"}`,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user