fix: sendViaOpenClaw uses send-then-poll pattern for chat.send

chat.send returns immediately with {runId, status: "started"}.
The previous approach expected the CLI to block until the agent responded.
Now: send via chat.send, then poll chat.history every 2s until the
assistant's response appears (matched by timestamp > sendTs).

Verified end-to-end: send → 5s wait → poll → response received.
153 tests passing, 0 type errors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
JohnRiceML
2026-03-02 13:47:29 -06:00
co-authored by Claude Opus 4.6
parent 3ab27384b8
commit 1132b3ab44
2 changed files with 246 additions and 162 deletions
+155 -86
View File
@@ -5,6 +5,7 @@ import {
extractImageAttachments,
buildTextPrompt,
sendViaOpenClaw,
execCli,
} from './anthropic'
import type { ApiMessage } from './validation'
@@ -175,7 +176,6 @@ describe('buildTextPrompt', () => {
]
const result = buildTextPrompt('', msgs)
expect(result).toContain('what do you see?')
// Should not contain the data URL
expect(result).not.toContain('data:image')
})
@@ -185,14 +185,13 @@ describe('buildTextPrompt', () => {
{ role: 'user', content: 'question' },
]
const result = buildTextPrompt('main system', msgs)
// System prompt is the first arg, system messages in array are skipped
expect(result).toContain('main system')
expect(result).toContain('question')
expect(result).not.toContain('extra system')
})
})
// --- sendViaOpenClaw ---
// --- execCli ---
vi.mock('child_process', () => ({
execFile: vi.fn(),
@@ -200,55 +199,105 @@ vi.mock('child_process', () => ({
import { execFile as mockExecFile } from 'child_process'
describe('execCli', () => {
beforeEach(() => {
vi.mocked(mockExecFile).mockReset()
})
it('returns stdout on success', async () => {
vi.mocked(mockExecFile).mockImplementation((_cmd, _args, _opts, cb) => {
(cb as (err: Error | null, stdout: string, stderr: string) => void)(null, 'output', '')
return {} as ReturnType<typeof mockExecFile>
})
const result = await execCli('/usr/bin/openclaw', ['arg1'], 5000)
expect(result).toBe('output')
})
it('returns null on error', async () => {
vi.mocked(mockExecFile).mockImplementation((_cmd, _args, _opts, cb) => {
(cb as (err: Error | null, stdout: string, stderr: string) => void)(new Error('fail'), '', '')
return {} as ReturnType<typeof mockExecFile>
})
const result = await execCli('/usr/bin/openclaw', ['arg1'], 5000)
expect(result).toBeNull()
})
})
// --- sendViaOpenClaw ---
describe('sendViaOpenClaw', () => {
beforeEach(() => {
vi.stubEnv('OPENCLAW_BIN', '/usr/bin/openclaw')
vi.mocked(mockExecFile).mockReset()
vi.useFakeTimers({ shouldAdvanceTime: true })
})
afterEach(() => {
vi.unstubAllEnvs()
vi.useRealTimers()
})
it('calls execFile with correct arguments', async () => {
vi.mocked(mockExecFile).mockImplementation((_cmd, _args, _opts, cb) => {
(cb as (err: Error | null, stdout: string, stderr: string) => void)(
null,
JSON.stringify({ content: 'I see a cat' }),
''
)
it('sends chat.send then polls chat.history for response', async () => {
let callCount = 0
vi.mocked(mockExecFile).mockImplementation((_cmd, args, _opts, cb) => {
callCount++
const argsArr = args as string[]
if (argsArr.includes('chat.send')) {
// Step 1: send returns started
(cb as (err: Error | null, stdout: string, stderr: string) => void)(
null,
JSON.stringify({ runId: 'run-1', status: 'started' }),
''
)
} else if (argsArr.includes('chat.history')) {
if (callCount <= 2) {
// First poll: still processing (last msg is user)
(cb as (err: Error | null, stdout: string, stderr: string) => void)(
null,
JSON.stringify({
messages: [
{ role: 'user', content: [{ type: 'text', text: 'describe' }], timestamp: Date.now() },
],
}),
''
)
} else {
// Second poll: assistant responded
(cb as (err: Error | null, stdout: string, stderr: string) => void)(
null,
JSON.stringify({
messages: [
{ role: 'user', content: [{ type: 'text', text: 'describe' }], timestamp: Date.now() },
{
role: 'assistant',
content: [
{ type: 'thinking', thinking: 'analyzing...' },
{ type: 'text', text: 'I see a Discord bot profile for Jarvis.' },
],
timestamp: Date.now(),
},
],
}),
''
)
}
}
return {} as ReturnType<typeof mockExecFile>
})
const result = await sendViaOpenClaw({
gatewayToken: 'test-token',
message: 'describe this',
message: 'describe this image',
attachments: [{ mimeType: 'image/png', content: 'base64data' }],
})
expect(result).toBe('I see a cat')
const calls = vi.mocked(mockExecFile).mock.calls
expect(calls).toHaveLength(1)
const [bin, args] = calls[0]
expect(bin).toBe('/usr/bin/openclaw')
expect(args).toContain('gateway')
expect(args).toContain('call')
expect(args).toContain('chat.send')
expect(args).toContain('--expect-final')
expect(args).toContain('--json')
expect(args).toContain('--token')
expect(args).toContain('test-token')
// Verify params JSON includes our data
const paramsIdx = (args as string[]).indexOf('--params')
const paramsJson = JSON.parse((args as string[])[paramsIdx + 1])
expect(paramsJson.message).toBe('describe this')
expect(paramsJson.attachments).toHaveLength(1)
expect(paramsJson.attachments[0].mimeType).toBe('image/png')
expect(result).toBe('I see a Discord bot profile for Jarvis.')
// Should have called: 1 send + at least 2 history polls
expect(callCount).toBeGreaterThanOrEqual(3)
})
it('returns null on execFile error', async () => {
it('returns null when chat.send fails', async () => {
vi.mocked(mockExecFile).mockImplementation((_cmd, _args, _opts, cb) => {
(cb as (err: Error | null, stdout: string, stderr: string) => void)(
new Error('spawn E2BIG'),
@@ -267,49 +316,11 @@ describe('sendViaOpenClaw', () => {
expect(result).toBeNull()
})
it('handles plain text stdout as fallback', async () => {
it('returns null when send response is unexpected', async () => {
vi.mocked(mockExecFile).mockImplementation((_cmd, _args, _opts, cb) => {
(cb as (err: Error | null, stdout: string, stderr: string) => void)(
null,
'Hello, I can see a dog in the image.',
''
)
return {} as ReturnType<typeof mockExecFile>
})
const result = await sendViaOpenClaw({
gatewayToken: 'test-token',
message: 'what is this?',
attachments: [{ mimeType: 'image/jpeg', content: 'abc' }],
})
expect(result).toBe('Hello, I can see a dog in the image.')
})
it('extracts content from nested result object', async () => {
vi.mocked(mockExecFile).mockImplementation((_cmd, _args, _opts, cb) => {
(cb as (err: Error | null, stdout: string, stderr: string) => void)(
null,
JSON.stringify({ ok: true, result: { content: 'nested response' } }),
''
)
return {} as ReturnType<typeof mockExecFile>
})
const result = await sendViaOpenClaw({
gatewayToken: 'test-token',
message: 'test',
attachments: [],
})
expect(result).toBe('nested response')
})
it('returns null for empty stdout', async () => {
vi.mocked(mockExecFile).mockImplementation((_cmd, _args, _opts, cb) => {
(cb as (err: Error | null, stdout: string, stderr: string) => void)(
null,
'',
JSON.stringify({ error: 'bad request' }),
''
)
return {} as ReturnType<typeof mockExecFile>
@@ -324,30 +335,88 @@ describe('sendViaOpenClaw', () => {
expect(result).toBeNull()
})
it('uses custom sessionKey and timeout when provided', async () => {
vi.mocked(mockExecFile).mockImplementation((_cmd, _args, _opts, cb) => {
(cb as (err: Error | null, stdout: string, stderr: string) => void)(
null,
JSON.stringify({ content: 'ok' }),
''
)
it('passes correct params to chat.send', async () => {
vi.mocked(mockExecFile).mockImplementation((_cmd, args, _opts, cb) => {
const argsArr = args as string[]
if (argsArr.includes('chat.send')) {
(cb as (err: Error | null, stdout: string, stderr: string) => void)(
null,
JSON.stringify({ runId: 'r1', status: 'started' }),
''
)
} else {
// Return assistant response immediately
(cb as (err: Error | null, stdout: string, stderr: string) => void)(
null,
JSON.stringify({
messages: [{
role: 'assistant',
content: [{ type: 'text', text: 'ok' }],
timestamp: Date.now(),
}],
}),
''
)
}
return {} as ReturnType<typeof mockExecFile>
})
await sendViaOpenClaw({
gatewayToken: 'tok',
message: 'hi',
attachments: [],
gatewayToken: 'my-token',
message: 'describe this',
attachments: [{ mimeType: 'image/jpeg', content: 'imgdata' }],
sessionKey: 'custom:session',
timeoutMs: 30000,
})
const [, args] = vi.mocked(mockExecFile).mock.calls[0]
// Find the chat.send call
const sendCall = vi.mocked(mockExecFile).mock.calls.find(
c => (c[1] as string[]).includes('chat.send')
)
expect(sendCall).toBeTruthy()
const [bin, args] = sendCall!
expect(bin).toBe('/usr/bin/openclaw')
expect(args).toContain('--token')
expect(args).toContain('my-token')
const paramsIdx = (args as string[]).indexOf('--params')
const paramsJson = JSON.parse((args as string[])[paramsIdx + 1])
expect(paramsJson.sessionKey).toBe('custom:session')
expect(paramsJson.message).toBe('describe this')
expect(paramsJson.attachments).toHaveLength(1)
expect(paramsJson.attachments[0].mimeType).toBe('image/jpeg')
})
const timeoutIdx = (args as string[]).indexOf('--timeout')
expect((args as string[])[timeoutIdx + 1]).toBe('30000')
it('handles string content in assistant response', async () => {
vi.mocked(mockExecFile).mockImplementation((_cmd, args, _opts, cb) => {
const argsArr = args as string[]
if (argsArr.includes('chat.send')) {
(cb as (err: Error | null, stdout: string, stderr: string) => void)(
null,
JSON.stringify({ runId: 'r1', status: 'started' }),
''
)
} else {
(cb as (err: Error | null, stdout: string, stderr: string) => void)(
null,
JSON.stringify({
messages: [{
role: 'assistant',
content: 'plain string response',
timestamp: Date.now(),
}],
}),
''
)
}
return {} as ReturnType<typeof mockExecFile>
})
const result = await sendViaOpenClaw({
gatewayToken: 'tok',
message: 'hi',
attachments: [],
})
expect(result).toBe('plain string response')
})
})
+91 -76
View File
@@ -3,9 +3,9 @@
*
* The gateway's /v1/chat/completions endpoint strips image_url content parts.
* Images work through the agent pipeline (chat.send), which is the same path
* Discord/Telegram/etc use. We invoke the CLI directly via execFile.
* Discord/Telegram/etc use. We invoke the CLI to send, then poll chat.history.
*
* Flow: extract images as attachments → CLI chat.send → parse response → return
* Flow: extract images → CLI chat.send → poll chat.history → extract response
*/
import { execFile } from 'child_process'
@@ -79,8 +79,32 @@ export function buildTextPrompt(systemPrompt: string, messages: ApiMessage[]): s
}
/**
* Send a vision message through the OpenClaw gateway via CLI (execFile).
* Runs `openclaw gateway call chat.send --params <json> --expect-final`.
* Run openclaw CLI and return stdout, or null on error.
*/
export function execCli(
bin: string,
args: string[],
timeoutMs: number
): Promise<string | null> {
return new Promise((resolve) => {
execFile(bin, args, { timeout: timeoutMs, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {
if (err) {
console.error('execCli error:', err.message)
if (stderr) console.error('stderr:', stderr)
resolve(null)
return
}
resolve(stdout)
})
})
}
/**
* Send a vision message through the OpenClaw gateway via CLI.
*
* Two-step process:
* 1. `openclaw gateway call chat.send` — fires the message (returns immediately)
* 2. Poll `openclaw gateway call chat.history` — wait for the assistant response
*
* Images must be resized client-side to fit within the OS argument size limit.
*
@@ -97,93 +121,84 @@ export async function sendViaOpenClaw(opts: {
const sessionKey = opts.sessionKey || 'agent:main:manor-ui'
const idempotencyKey = `manor-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const timeoutMs = opts.timeoutMs || 60000
const token = opts.gatewayToken
const params = JSON.stringify({
// Timestamp before sending — used to identify the new response
const sendTs = Date.now()
// Step 1: Send the message via chat.send
const sendParams = JSON.stringify({
sessionKey,
idempotencyKey,
message: opts.message,
attachments: opts.attachments,
})
return new Promise<string | null>((resolve) => {
const args = [
'gateway', 'call', 'chat.send',
'--params', params,
'--expect-final',
'--timeout', String(timeoutMs),
'--token', opts.gatewayToken,
const sendResult = await execCli(openclawBin, [
'gateway', 'call', 'chat.send',
'--params', sendParams,
'--token', token,
'--json',
], 15000)
if (sendResult === null) {
return null
}
// Verify send was accepted
try {
const sendData = JSON.parse(sendResult)
if (sendData.status !== 'started' && !sendData.runId) {
console.error('sendViaOpenClaw: unexpected send response:', sendResult)
return null
}
} catch {
console.error('sendViaOpenClaw: failed to parse send response:', sendResult)
return null
}
// Step 2: Poll chat.history for the assistant response
const pollIntervalMs = 2000
const historyParams = JSON.stringify({ sessionKey })
const deadline = sendTs + timeoutMs
while (Date.now() < deadline) {
await new Promise(r => setTimeout(r, pollIntervalMs))
const historyResult = await execCli(openclawBin, [
'gateway', 'call', 'chat.history',
'--params', historyParams,
'--token', token,
'--json',
]
], 10000)
execFile(openclawBin, args, { timeout: timeoutMs + 5000, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {
if (err) {
console.error('sendViaOpenClaw execFile error:', err.message)
if (stderr) console.error('stderr:', stderr)
resolve(null)
return
}
if (!historyResult) continue
try {
const result = JSON.parse(stdout)
const content = extractCliResponse(result)
resolve(content)
} catch {
// stdout might be plain text response
const trimmed = stdout.trim()
if (trimmed) {
resolve(trimmed)
} else {
console.error('sendViaOpenClaw: empty response')
resolve(null)
try {
const history = JSON.parse(historyResult)
const messages = history.messages || []
if (messages.length === 0) continue
const lastMsg = messages[messages.length - 1]
// Wait for an assistant message that arrived after we sent
if (lastMsg.role === 'assistant' && lastMsg.timestamp >= sendTs) {
const content = lastMsg.content
if (typeof content === 'string') return content
if (Array.isArray(content)) {
const textParts = content
.filter((p: { type: string }) => p.type === 'text')
.map((p: { text: string }) => p.text)
.join('\n')
return textParts || null
}
}
})
})
}
/**
* Extract the assistant's response from the CLI JSON output.
* The CLI can return responses in several formats.
*/
function extractCliResponse(data: Record<string, unknown>): string | null {
// Direct content field
if (typeof data.content === 'string' && data.content) {
return data.content
}
// Response text field
if (typeof data.text === 'string' && data.text) {
return data.text
}
// Reply field
if (typeof data.reply === 'string' && data.reply) {
return data.reply
}
// Nested in result/payload
if (data.result && typeof data.result === 'object') {
const result = data.result as Record<string, unknown>
if (typeof result.content === 'string' && result.content) return result.content
if (typeof result.text === 'string' && result.text) return result.text
if (result.message && typeof result.message === 'object') {
const msg = result.message as Record<string, unknown>
if (typeof msg.content === 'string' && msg.content) return msg.content
} catch {
// Parse error — try again next poll
}
}
if (data.payload && typeof data.payload === 'object') {
const payload = data.payload as Record<string, unknown>
if (typeof payload.content === 'string' && payload.content) return payload.content
if (typeof payload.text === 'string' && payload.text) return payload.text
}
// ok: true with message
if (data.ok && data.message && typeof data.message === 'object') {
const msg = data.message as Record<string, unknown>
if (typeof msg.content === 'string' && msg.content) return msg.content
}
console.error('sendViaOpenClaw: timed out waiting for response')
return null
}