mirror of
https://github.com/JohnRiceML/clawport-ui.git
synced 2026-08-14 08:51:58 +00:00
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:
co-authored by
Claude Opus 4.6
parent
3ab27384b8
commit
1132b3ab44
+155
-86
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user