Skip to content

Fix: Convert literal \n to actual newlines in GitHub CLI interactions #366

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Mar 24, 2025
Merged
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
50 changes: 50 additions & 0 deletions docs/github-cli-usage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# GitHub CLI Usage in MyCoder

This document explains how to properly use the GitHub CLI (`gh`) with MyCoder, especially when creating issues, PRs, or comments with multiline content.

## Using `stdinContent` for Multiline Content

When creating GitHub issues, PRs, or comments via the `gh` CLI tool, always use the `stdinContent` parameter for multiline content:

```javascript
shellStart({
command: 'gh issue create --body-stdin',
stdinContent:
'Issue description here with **markdown** support\nThis is a new line',
description: 'Creating a new issue',
});
```

## Handling Newlines

MyCoder automatically handles newlines in two ways:

1. **Actual newlines** in template literals:

```javascript
stdinContent: `Line 1
Line 2
Line 3`;
```

2. **Escaped newlines** in regular strings:
```javascript
stdinContent: 'Line 1\\nLine 2\\nLine 3';
```

Both approaches will result in properly formatted multiline content in GitHub. MyCoder automatically converts literal `\n` sequences to actual newlines before sending the content to the GitHub CLI.

## Best Practices

- Use template literals (backticks) for multiline content whenever possible, as they're more readable
- When working with dynamic strings that might contain `\n`, don't worry - MyCoder will handle the conversion automatically
- Always use `--body-stdin` (or equivalent) flags with the GitHub CLI to ensure proper formatting
- For very large content, consider using `--body-file` with a temporary file instead

## Common Issues

If you notice that your GitHub comments or PR descriptions still contain literal `\n` sequences:

1. Make sure you're using the `stdinContent` parameter with `shellStart` or `shellExecute`
2. Verify that you're using the correct GitHub CLI flags (e.g., `--body-stdin`)
3. Check if your content is being processed by another function before reaching `stdinContent` that might be escaping the newlines
84 changes: 80 additions & 4 deletions packages/agent/src/tools/shell/shellExecute.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,85 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';

// Skip testing for now
describe.skip('shellExecuteTool', () => {
it('should execute a shell command', async () => {
import { shellExecuteTool } from './shellExecute';

// Mock child_process.exec
vi.mock('child_process', () => ({
exec: vi.fn(),
}));

// Mock util.promisify to return our mocked exec function
vi.mock('util', () => ({
promisify: vi.fn((fn) => fn),
}));

describe('shellExecuteTool', () => {
// Original test - skipped
it.skip('should execute a shell command', async () => {
// This is a dummy test that will be skipped
expect(true).toBe(true);
});

// New test for newline conversion
it('should properly convert literal newlines in stdinContent', async () => {
// Setup
const { exec } = await import('child_process');
const stdinWithLiteralNewlines = 'Line 1\\nLine 2\\nLine 3';
const expectedProcessedContent = 'Line 1\nLine 2\nLine 3';

// Create a minimal mock context
const mockContext = {
logger: {
debug: vi.fn(),
error: vi.fn(),
log: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
},
workingDirectory: '/test',
headless: false,
userSession: false,
tokenTracker: { trackTokens: vi.fn() },
githubMode: false,
provider: 'anthropic',
maxTokens: 4000,
temperature: 0,
agentTracker: { registerAgent: vi.fn() },
shellTracker: { registerShell: vi.fn(), processStates: new Map() },
browserTracker: { registerSession: vi.fn() },
};

// Create a real Buffer but spy on the toString method
const realBuffer = Buffer.from('test');
const bufferSpy = vi
.spyOn(Buffer, 'from')
.mockImplementationOnce((content) => {
// Store the actual content for verification
if (typeof content === 'string') {
// This is where we verify the content has been transformed
expect(content).toEqual(expectedProcessedContent);
}
return realBuffer;
});

// Mock exec to resolve with empty stdout/stderr
(exec as any).mockImplementationOnce((cmd, opts, callback) => {
callback(null, { stdout: '', stderr: '' });
});

// Execute the tool with literal newlines in stdinContent
await shellExecuteTool.execute(
{
command: 'cat',
description: 'Testing literal newline conversion',
stdinContent: stdinWithLiteralNewlines,
},
mockContext as any,
);

// Verify the Buffer.from was called
expect(bufferSpy).toHaveBeenCalled();

// Reset mocks
bufferSpy.mockRestore();
});
});
3 changes: 3 additions & 0 deletions packages/agent/src/tools/shell/shellExecute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@ export const shellExecuteTool: Tool<Parameters, ReturnType> = {

// If stdinContent is provided, use platform-specific approach to pipe content
if (stdinContent && stdinContent.length > 0) {
// Replace literal \n with actual newlines and \t with actual tabs
stdinContent = stdinContent.replace(/\\n/g, '\n').replace(/\\t/g, '\t');

const isWindows = process.platform === 'win32';
const encodedContent = Buffer.from(stdinContent).toString('base64');

Expand Down
42 changes: 42 additions & 0 deletions packages/agent/src/tools/shell/shellStart.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,4 +192,46 @@ describe('shellStartTool', () => {
'With stdin content of length: 12',
);
});

it('should properly convert literal newlines in stdinContent', async () => {
await import('child_process');
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', {
value: 'darwin',
writable: true,
});

const stdinWithLiteralNewlines = 'Line 1\\nLine 2\\nLine 3';
const expectedProcessedContent = 'Line 1\nLine 2\nLine 3';

// Capture the actual content being passed to Buffer.from
let capturedContent = '';
vi.spyOn(Buffer, 'from').mockImplementationOnce((content) => {
if (typeof content === 'string') {
capturedContent = content;
}
// Call the real implementation for encoding
return Buffer.from(content);
});

await shellStartTool.execute(
{
command: 'cat',
description: 'Testing literal newline conversion',
timeout: 0,
stdinContent: stdinWithLiteralNewlines,
},
mockToolContext,
);

// Verify that the literal newlines were converted to actual newlines
expect(capturedContent).toEqual(expectedProcessedContent);

// Reset mocks and platform
vi.spyOn(Buffer, 'from').mockRestore();
Object.defineProperty(process, 'platform', {
value: originalPlatform,
writable: true,
});
});
});
5 changes: 5 additions & 0 deletions packages/agent/src/tools/shell/shellStart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,11 @@ export const shellStartTool: Tool<Parameters, ReturnType> = {
let childProcess;

if (stdinContent && stdinContent.length > 0) {
// Replace literal \n with actual newlines and \t with actual tabs
stdinContent = stdinContent
.replace(/\\n/g, '\n')
.replace(/\\t/g, '\t');

if (isWindows) {
// Windows approach using PowerShell
const encodedContent = Buffer.from(stdinContent).toString('base64');
Expand Down
3 changes: 3 additions & 0 deletions test_content.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
This is line 1.
This is line 2.
This is line 3.
Loading