mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
fix(memory): deliver OpenCode extraction prompt on stdin (#3238)
`opencode run`'s `-f, --file` is a yargs array option that greedily consumes every trailing non-flag token, so the memory extractor's `--file <prompt-file> "<message>"` invocation made OpenCode treat the message text as a second attachment and exit 1 with "File not found". Every LLM memory extraction failed for OpenCode Local CLI users. Deliver the prompt on stdin like the chat-run path (def.promptViaStdin) and drop the --file attachment. The connector-memory test now models the real yargs --file array-greediness so it would catch a regression.
This commit is contained in:
parent
f12679185c
commit
7a9dcf38d7
2 changed files with 42 additions and 42 deletions
|
|
@ -61,9 +61,6 @@ import {
|
||||||
} from './memory-extractions.js';
|
} from './memory-extractions.js';
|
||||||
import { resolveProviderConfig } from './media-config.js';
|
import { resolveProviderConfig } from './media-config.js';
|
||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
import { promises as fsp } from 'node:fs';
|
|
||||||
import os from 'node:os';
|
|
||||||
import path from 'node:path';
|
|
||||||
import { createCommandInvocation } from '@open-design/platform';
|
import { createCommandInvocation } from '@open-design/platform';
|
||||||
import {
|
import {
|
||||||
applyAgentLaunchEnv,
|
applyAgentLaunchEnv,
|
||||||
|
|
@ -789,16 +786,6 @@ function extractJsonEventText(kind, raw, agentName) {
|
||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function writeLocalCliPromptAttachment(agentId, prompt) {
|
|
||||||
const dir = await fsp.mkdtemp(path.join(os.tmpdir(), `od-memory-${agentId}-`));
|
|
||||||
const file = path.join(dir, 'prompt.md');
|
|
||||||
await fsp.writeFile(file, prompt, 'utf8');
|
|
||||||
return {
|
|
||||||
file,
|
|
||||||
cleanup: () => fsp.rm(dir, { recursive: true, force: true }).catch(() => {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function callLocalCli(provider, system, user, options) {
|
async function callLocalCli(provider, system, user, options) {
|
||||||
if (typeof options?.localCliRunner === 'function') {
|
if (typeof options?.localCliRunner === 'function') {
|
||||||
return options.localCliRunner({
|
return options.localCliRunner({
|
||||||
|
|
@ -843,7 +830,6 @@ async function callLocalCli(provider, system, user, options) {
|
||||||
|
|
||||||
let args;
|
let args;
|
||||||
let stdinText = prompt;
|
let stdinText = prompt;
|
||||||
let cleanupPromptAttachment = () => Promise.resolve();
|
|
||||||
let parseStdout = (raw) => raw.trim();
|
let parseStdout = (raw) => raw.trim();
|
||||||
if (provider.agentId === 'claude') {
|
if (provider.agentId === 'claude') {
|
||||||
args = ['-p', '--input-format', 'text', '--output-format', 'text'];
|
args = ['-p', '--input-format', 'text', '--output-format', 'text'];
|
||||||
|
|
@ -860,8 +846,12 @@ async function callLocalCli(provider, system, user, options) {
|
||||||
);
|
);
|
||||||
parseStdout = (raw) => extractJsonEventText(def.eventParser || def.id, raw, def.name);
|
parseStdout = (raw) => extractJsonEventText(def.eventParser || def.id, raw, def.name);
|
||||||
} else if (provider.agentId === 'opencode') {
|
} else if (provider.agentId === 'opencode') {
|
||||||
const attachment = await writeLocalCliPromptAttachment(provider.agentId, prompt);
|
// Deliver the prompt on stdin, matching the chat-run path
|
||||||
cleanupPromptAttachment = attachment.cleanup;
|
// (def.promptViaStdin). `opencode run`'s `-f, --file` is a yargs array
|
||||||
|
// option that greedily consumes every trailing non-flag token, so
|
||||||
|
// `--file <prompt-file> "<message>"` made OpenCode treat the message
|
||||||
|
// text as a second attachment and exit with "File not found". Bare
|
||||||
|
// `opencode run --format json` reads the message from stdin instead.
|
||||||
args = def.buildArgs(
|
args = def.buildArgs(
|
||||||
'',
|
'',
|
||||||
[],
|
[],
|
||||||
|
|
@ -869,12 +859,6 @@ async function callLocalCli(provider, system, user, options) {
|
||||||
{ model: provider.model },
|
{ model: provider.model },
|
||||||
{ cwd },
|
{ cwd },
|
||||||
);
|
);
|
||||||
args.push(
|
|
||||||
'--file',
|
|
||||||
attachment.file,
|
|
||||||
'Read the attached OpenDesign memory extraction prompt and return strict JSON only.',
|
|
||||||
);
|
|
||||||
stdinText = '';
|
|
||||||
parseStdout = (raw) => extractJsonEventText(def.eventParser || def.id, raw, def.name);
|
parseStdout = (raw) => extractJsonEventText(def.eventParser || def.id, raw, def.name);
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`Local CLI memory extraction is not supported for ${provider.agentId}`);
|
throw new Error(`Local CLI memory extraction is not supported for ${provider.agentId}`);
|
||||||
|
|
@ -907,10 +891,8 @@ async function callLocalCli(provider, system, user, options) {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
settled = true;
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
void cleanupPromptAttachment().finally(() => {
|
if (err) reject(err);
|
||||||
if (err) reject(err);
|
else resolve(text);
|
||||||
else resolve(text);
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
|
|
|
||||||
|
|
@ -1023,7 +1023,7 @@ process.stdout.write(JSON.stringify({
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('runs OpenCode Local CLI with a message argument and attached prompt file', async () => {
|
it('runs OpenCode Local CLI memory extraction with the prompt on stdin', async () => {
|
||||||
await writeMemoryConfig(dataDir, { extraction: null });
|
await writeMemoryConfig(dataDir, { extraction: null });
|
||||||
const tempDir = await fsp.mkdtemp(path.join(tmpdir(), 'od-opencode-memory-'));
|
const tempDir = await fsp.mkdtemp(path.join(tmpdir(), 'od-opencode-memory-'));
|
||||||
const binPath = path.join(tempDir, 'opencode-cli');
|
const binPath = path.join(tempDir, 'opencode-cli');
|
||||||
|
|
@ -1031,16 +1031,33 @@ process.stdout.write(JSON.stringify({
|
||||||
const previousPath = process.env.PATH;
|
const previousPath = process.env.PATH;
|
||||||
const previousCapture = process.env.OD_MEMORY_OPENCODE_ARGS_OUT;
|
const previousCapture = process.env.OD_MEMORY_OPENCODE_ARGS_OUT;
|
||||||
|
|
||||||
|
// Model the real `opencode run` arg parser: `-f, --file` is a yargs
|
||||||
|
// *array* option, so it greedily swallows every following non-flag
|
||||||
|
// token as a file path. Any captured path that doesn't exist makes the
|
||||||
|
// real CLI exit 1 with "File not found: <token>" — which is exactly how
|
||||||
|
// a trailing positional message after `--file` crashed extraction. The
|
||||||
|
// supported one-shot shape is bare `run` with the prompt on stdin.
|
||||||
await fsp.writeFile(
|
await fsp.writeFile(
|
||||||
binPath,
|
binPath,
|
||||||
`#!/usr/bin/env node
|
`#!/usr/bin/env node
|
||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
const fileIndex = args.indexOf('--file');
|
|
||||||
const attachedFile = fileIndex >= 0 ? args[fileIndex + 1] : null;
|
|
||||||
const prompt = attachedFile ? fs.readFileSync(attachedFile, 'utf8') : '';
|
|
||||||
const stdin = fs.readFileSync(0, 'utf8');
|
const stdin = fs.readFileSync(0, 'utf8');
|
||||||
fs.writeFileSync(process.env.OD_MEMORY_OPENCODE_ARGS_OUT, JSON.stringify({ args, attachedFile, prompt, stdin }));
|
const files = [];
|
||||||
|
const fileFlag = args.findIndex((a) => a === '--file' || a === '-f');
|
||||||
|
if (fileFlag >= 0) {
|
||||||
|
for (let i = fileFlag + 1; i < args.length; i += 1) {
|
||||||
|
if (args[i].startsWith('-')) break;
|
||||||
|
files.push(args[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fs.writeFileSync(process.env.OD_MEMORY_OPENCODE_ARGS_OUT, JSON.stringify({ args, stdin, files }));
|
||||||
|
for (const f of files) {
|
||||||
|
if (!fs.existsSync(f)) {
|
||||||
|
process.stderr.write('Error: File not found: ' + f + '\\n');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
process.stdout.write(JSON.stringify({
|
process.stdout.write(JSON.stringify({
|
||||||
type: 'text',
|
type: 'text',
|
||||||
part: {
|
part: {
|
||||||
|
|
@ -1048,9 +1065,9 @@ process.stdout.write(JSON.stringify({
|
||||||
text: JSON.stringify({
|
text: JSON.stringify({
|
||||||
entries: [{
|
entries: [{
|
||||||
type: 'project',
|
type: 'project',
|
||||||
name: 'OpenCode prompt attachment',
|
name: 'OpenCode stdin prompt',
|
||||||
description: 'OpenCode memory used a prompt file',
|
description: 'OpenCode memory used stdin',
|
||||||
body: 'OpenDesign connector memory extraction should pass the compacted prompt to OpenCode as an attached file while sending a short message argument.'
|
body: 'OpenDesign connector memory extraction should pass the compacted prompt to OpenCode on stdin and parse the JSON event stream response.'
|
||||||
}]
|
}]
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -1077,7 +1094,7 @@ process.stdout.write(JSON.stringify({
|
||||||
expect(result.suggestions).toEqual([
|
expect(result.suggestions).toEqual([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
type: 'project',
|
type: 'project',
|
||||||
name: 'OpenCode prompt attachment',
|
name: 'OpenCode stdin prompt',
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -1086,14 +1103,15 @@ process.stdout.write(JSON.stringify({
|
||||||
'run',
|
'run',
|
||||||
'--format',
|
'--format',
|
||||||
'json',
|
'json',
|
||||||
'--file',
|
'openai/gpt-5',
|
||||||
'Read the attached OpenDesign memory extraction prompt and return strict JSON only.',
|
|
||||||
]));
|
]));
|
||||||
expect(captured.args).toContain('openai/gpt-5');
|
// The prompt rides on stdin like the chat-run path; no `--file`
|
||||||
expect(captured.prompt).toContain('You are a design-memory extractor');
|
// attachment (whose array option would swallow any trailing message).
|
||||||
expect(captured.prompt).toContain('OpenDesign connector memory should collect design preferences');
|
expect(captured.args).not.toContain('--file');
|
||||||
expect(captured.stdin).toBe('');
|
expect(captured.args).not.toContain('-f');
|
||||||
await expect(fsp.access(captured.attachedFile)).rejects.toThrow();
|
expect(captured.files).toEqual([]);
|
||||||
|
expect(captured.stdin).toContain('You are a design-memory extractor');
|
||||||
|
expect(captured.stdin).toContain('OpenDesign connector memory should collect design preferences');
|
||||||
} finally {
|
} finally {
|
||||||
if (previousPath == null) {
|
if (previousPath == null) {
|
||||||
delete process.env.PATH;
|
delete process.env.PATH;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue