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];
|
const argv = runSpy.mock.calls[0][0];
|
||||||
expect(argv[0]).toBe("claude");
|
expect(argv[0]).toBe("claude");
|
||||||
expect(argv.at(-1)).toBe("hi");
|
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);
|
expect(argv.includes("-p") || argv.includes("--print")).toBe(true);
|
||||||
const outputIdx = argv.findIndex(
|
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(outputIdx).toBeGreaterThan(-1);
|
||||||
expect(argv[outputIdx + 1]).toBe("text");
|
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", () => {
|
describe("twilio interactions", () => {
|
||||||
|
|||||||
74
src/index.ts
74
src/index.ts
@@ -552,11 +552,69 @@ type TemplateContext = MsgContext & {
|
|||||||
IsNewSession?: string;
|
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 };
|
type SessionEntry = { sessionId: string; updatedAt: number };
|
||||||
|
|
||||||
const SESSION_STORE_DEFAULT = path.join(CONFIG_DIR, "sessions.json");
|
const SESSION_STORE_DEFAULT = path.join(CONFIG_DIR, "sessions.json");
|
||||||
const DEFAULT_RESET_TRIGGER = "/new";
|
const DEFAULT_RESET_TRIGGER = "/new";
|
||||||
const DEFAULT_IDLE_MINUTES = 60;
|
const DEFAULT_IDLE_MINUTES = 60;
|
||||||
|
const CLAUDE_BIN = "claude";
|
||||||
|
|
||||||
function resolveStorePath(store?: string) {
|
function resolveStorePath(store?: string) {
|
||||||
if (!store) return SESSION_STORE_DEFAULT;
|
if (!store) return SESSION_STORE_DEFAULT;
|
||||||
@@ -722,7 +780,7 @@ async function getReplyFromConfig(
|
|||||||
if (
|
if (
|
||||||
reply.claudeOutputFormat &&
|
reply.claudeOutputFormat &&
|
||||||
argv.length > 0 &&
|
argv.length > 0 &&
|
||||||
path.basename(argv[0]) === "claude"
|
path.basename(argv[0]) === CLAUDE_BIN
|
||||||
) {
|
) {
|
||||||
const hasOutputFormat = argv.some(
|
const hasOutputFormat = argv.some(
|
||||||
(part) =>
|
(part) =>
|
||||||
@@ -773,10 +831,22 @@ async function getReplyFromConfig(
|
|||||||
finalArgv,
|
finalArgv,
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
);
|
);
|
||||||
const trimmed = stdout.trim();
|
let trimmed = stdout.trim();
|
||||||
if (stderr?.trim()) {
|
if (stderr?.trim()) {
|
||||||
logVerbose(`Command auto-reply stderr: ${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(
|
logVerbose(
|
||||||
`Command auto-reply stdout (trimmed): ${trimmed || "<empty>"}`,
|
`Command auto-reply stdout (trimmed): ${trimmed || "<empty>"}`,
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user