Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 81 additions & 7 deletions lib/request/request-transformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,11 +324,58 @@ export function isOpenCodeSystemPrompt(
return contentText.startsWith("You are a coding agent running in");
}

/**
* Extract content text from an input item
* @param item - Input item
* @returns Content as string
*/
function getContentText(item: InputItem): string {
if (typeof item.content === "string") {
return item.content;
}
if (Array.isArray(item.content)) {
return item.content
.filter((c) => c.type === "input_text" && c.text)
.map((c) => c.text)
.join("\n");
}
return "";
}

/**
* Extract AGENTS.md content from a concatenated OpenCode message
*
* OpenCode concatenates multiple pieces into a single developer message:
* 1. Base codex.txt prompt (starts with "You are a coding agent running in...")
* 2. Environment info
* 3. <files> block
* 4. AGENTS.md content (prefixed with "Instructions from: /path/to/AGENTS.md")
*
* This function extracts the AGENTS.md portions so they can be preserved
* when filtering out the OpenCode base prompt.
*
* @param contentText - The full content text of the message
* @returns The AGENTS.md content if found, null otherwise
*/
function extractAgentsMdContent(contentText: string): string | null {
const marker = "Instructions from:";
const idx = contentText.indexOf(marker);
if (idx > 0) {
return contentText.slice(idx).trimStart();
}
return null;
}

/**
* Filter out OpenCode system prompts from input
* Used in CODEX_MODE to replace OpenCode prompts with Codex-OpenCode bridge
*
* When OpenCode sends a concatenated message containing both the base prompt
* AND AGENTS.md content, this function extracts and preserves the AGENTS.md
* portions while filtering out the OpenCode base prompt.
*
* @param input - Input array
* @returns Input array without OpenCode system prompts
* @returns Input array without OpenCode system prompts (but with AGENTS.md preserved)
*/
export async function filterOpenCodeSystemPrompts(
input: InputItem[] | undefined,
Expand All @@ -344,12 +391,39 @@ export async function filterOpenCodeSystemPrompts(
// This is safe because we still have the "starts with" check
}

return input.filter((item) => {
// Keep user messages
if (item.role === "user") return true;
// Filter out OpenCode system prompts
return !isOpenCodeSystemPrompt(item, cachedPrompt);
});
const result: InputItem[] = [];

for (const item of input) {
// Keep user messages as-is
if (item.role === "user") {
result.push(item);
continue;
}

// Check if this is an OpenCode system prompt
if (isOpenCodeSystemPrompt(item, cachedPrompt)) {
// OpenCode may concatenate AGENTS.md content with the base prompt
// Extract and preserve any AGENTS.md content
const contentText = getContentText(item);
const agentsMdContent = extractAgentsMdContent(contentText);

if (agentsMdContent) {
// Create a new message with just the AGENTS.md content
result.push({
type: "message",
role: "developer",
content: agentsMdContent,
});
}
// Filter out the OpenCode base prompt (don't add original item)
continue;
}

// Keep all other messages
result.push(item);
}

return result;
}

/**
Expand Down
127 changes: 127 additions & 0 deletions test/request-transformer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -544,6 +544,133 @@ describe('Request Transformer Module', () => {
it('should return undefined for undefined input', async () => {
expect(await filterOpenCodeSystemPrompts(undefined)).toBeUndefined();
});

// Tests for concatenated messages (single message containing both prompt AND AGENTS.md)
// This is how OpenCode actually sends content (as of v1.0.164+).
// The tests above cover separate messages pattern which may also occur.
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm assuming that separate messages either used to happen or it was just assumed to happen in the original implementation.

This PR doesn't remove that behaviour. So if the OpenCode implementation does change to separate messages, then it should continue to work. If you don't like that, feel free to change it.

describe('concatenated messages (OpenCode v1.0.164+ pattern)', () => {
it('should extract and preserve AGENTS.md when concatenated with OpenCode prompt', async () => {
// OpenCode sends a SINGLE message containing:
// 1. Base codex.txt prompt
// 2. Environment info
// 3. <files> block
// 4. AGENTS.md content (prefixed with "Instructions from:")
const input: InputItem[] = [
{
type: 'message',
role: 'developer',
content: `You are a coding agent running in the opencode, a terminal-based coding assistant.

Here is some useful information about the environment you are running in:
<env>
Working directory: /Users/test/project
Platform: darwin
</env>
<files>
src/
index.ts
</files>
Instructions from: /Users/test/project/AGENTS.md
# Project Guidelines

Use TypeScript for all new code.
Follow existing patterns in the codebase.

Instructions from: /Users/test/.config/opencode/AGENTS.md
# Global Settings

Always use mise for tool management.`,
},
{ type: 'message', role: 'user', content: 'hello' },
];

const result = await filterOpenCodeSystemPrompts(input);

// Should have 2 messages: extracted AGENTS.md content + user message
expect(result).toHaveLength(2);
expect(result![0].role).toBe('developer');
expect(result![0].content).toContain('Instructions from:');
expect(result![0].content).toContain('Project Guidelines');
expect(result![0].content).toContain('Global Settings');
// Should NOT contain the OpenCode base prompt
expect(result![0].content).not.toContain('You are a coding agent running in');
expect(result![1].role).toBe('user');
});

it('should preserve multiple AGENTS.md files in concatenated message', async () => {
const input: InputItem[] = [
{
type: 'message',
role: 'developer',
content: `You are a coding agent running in the opencode...

Instructions from: /project/AGENTS.md
# Project AGENTS.md
Project-specific instructions here.

Instructions from: /project/src/AGENTS.md
# Nested AGENTS.md
More specific instructions for src folder.

Instructions from: ~/.config/opencode/AGENTS.md
# Global AGENTS.md
Global instructions here.`,
},
{ type: 'message', role: 'user', content: 'test' },
];

const result = await filterOpenCodeSystemPrompts(input);

expect(result).toHaveLength(2);
// All AGENTS.md content should be preserved
expect(result![0].content).toContain('Project AGENTS.md');
expect(result![0].content).toContain('Nested AGENTS.md');
expect(result![0].content).toContain('Global AGENTS.md');
});

it('should handle concatenated message with no AGENTS.md (just base prompt)', async () => {
const input: InputItem[] = [
{
type: 'message',
role: 'developer',
content: 'You are a coding agent running in the opencode, a terminal-based coding assistant.',
},
{ type: 'message', role: 'user', content: 'hello' },
];

const result = await filterOpenCodeSystemPrompts(input);

// Should just have the user message (base prompt filtered, no AGENTS.md to preserve)
expect(result).toHaveLength(1);
expect(result![0].role).toBe('user');
});

it('should handle array content format in concatenated message', async () => {
const input: InputItem[] = [
{
type: 'message',
role: 'developer',
content: [
{
type: 'input_text',
text: `You are a coding agent running in the opencode...

Instructions from: /project/AGENTS.md
# My Custom Instructions
Do things this way.`,
},
],
},
{ type: 'message', role: 'user', content: 'hello' },
];

const result = await filterOpenCodeSystemPrompts(input);

expect(result).toHaveLength(2);
expect(result![0].content).toContain('My Custom Instructions');
expect(result![0].content).not.toContain('You are a coding agent');
});
});
});

describe('addCodexBridgeMessage', () => {
Expand Down