Skip to content

Commit 616cac8

Browse files
committed
Fix newline escape characters in GitHub messages (Issue #83)
1 parent 35cfaa4 commit 616cac8

File tree

5 files changed

+140
-5
lines changed

5 files changed

+140
-5
lines changed

packages/agent/src/core/toolAgent/config.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { execSync } from 'child_process';
2-
32
import { anthropic } from '@ai-sdk/anthropic';
43
import { openai } from '@ai-sdk/openai';
4+
import { getPlatformNewline } from '../../utils/github.js';
55

66
/**
77
* Available model providers
@@ -64,6 +64,8 @@ export function getDefaultSystemPrompt(toolContext: ToolContext): string {
6464
githubMode: toolContext.githubMode,
6565
};
6666

67+
// Use the platform-specific newline handling for GitHub CLI commands
68+
6769
const githubModeInstructions = context.githubMode
6870
? [
6971
'',
@@ -76,6 +78,8 @@ export function getDefaultSystemPrompt(toolContext: ToolContext): string {
7678
'- Create additional GitHub issues for follow-up tasks or ideas',
7779
'',
7880
'You can use the GitHub CLI (`gh`) for all GitHub interactions.',
81+
'',
82+
`When creating GitHub issues or PRs, use "${getPlatformNewline()}" for newlines in your text.`,
7983
].join('\n')
8084
: '';
8185

packages/agent/src/tools/system/shellStart.test.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
1+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
22

33
import { TokenTracker } from '../../core/tokens.js';
44
import { ToolContext } from '../../core/types.js';
5+
import * as githubUtils from '../../utils/github.js';
56
import { MockLogger } from '../../utils/mockLogger.js';
67
import { sleep } from '../../utils/sleep.js';
78

@@ -158,4 +159,46 @@ describe('shellStartTool', () => {
158159

159160
expect(result.mode).toBe('sync');
160161
});
162+
163+
it('should format GitHub CLI commands correctly in GitHub mode', async () => {
164+
// Create a GitHub mode context
165+
const githubModeContext: ToolContext = {
166+
...toolContext,
167+
githubMode: true,
168+
};
169+
170+
// Spy on formatGitHubText function
171+
const formatSpy = vi.spyOn(githubUtils, 'formatGitHubText');
172+
formatSpy.mockImplementation((text) => `FORMATTED:${text}`);
173+
174+
// Test with a GitHub CLI command that has a body
175+
await shellStartTool.execute(
176+
{
177+
command: 'gh issue create --title "Test Issue" --body "Line 1\\nLine 2"',
178+
description: 'GitHub CLI test',
179+
timeout: 100,
180+
},
181+
githubModeContext,
182+
);
183+
184+
// Verify formatGitHubText was called
185+
expect(formatSpy).toHaveBeenCalled();
186+
187+
// Test with a non-GitHub command (should not format)
188+
formatSpy.mockClear();
189+
await shellStartTool.execute(
190+
{
191+
command: 'echo "Not a GitHub command"',
192+
description: 'Non-GitHub command test',
193+
timeout: 100,
194+
},
195+
githubModeContext,
196+
);
197+
198+
// Verify formatGitHubText was not called for non-GitHub commands
199+
expect(formatSpy).not.toHaveBeenCalled();
200+
201+
// Restore the original implementation
202+
formatSpy.mockRestore();
203+
});
161204
});

packages/agent/src/tools/system/shellStart.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { z } from 'zod';
55
import { zodToJsonSchema } from 'zod-to-json-schema';
66

77
import { Tool } from '../../core/types.js';
8+
import { formatGitHubText } from '../../utils/github.js';
89
import { errorToString } from '../../utils/errorToString.js';
910

1011
import type { ChildProcess } from 'child_process';
@@ -79,24 +80,35 @@ export const shellStartTool: Tool<Parameters, ReturnType> = {
7980

8081
execute: async (
8182
{ command, timeout = DEFAULT_TIMEOUT },
82-
{ logger, workingDirectory },
83+
{ logger, workingDirectory, githubMode },
8384
): Promise<ReturnType> => {
8485
logger.verbose(`Starting shell command: ${command}`);
8586

87+
// If GitHub mode is enabled and this is a GitHub CLI command,
88+
// ensure newlines are properly escaped for the platform
89+
let processedCommand = command;
90+
if (githubMode && command.startsWith('gh ')) {
91+
// Only process commands that might contain content with newlines
92+
if (command.includes('--body') || command.includes('--title') || command.includes('--comment')) {
93+
processedCommand = formatGitHubText(command);
94+
logger.verbose(`Processed GitHub command with platform-specific newlines`);
95+
}
96+
}
97+
8698
return new Promise((resolve) => {
8799
try {
88100
const instanceId = uuidv4();
89101
let hasResolved = false;
90102

91103
// Split command into command and args
92104
// Use command directly with shell: true
93-
const process = spawn(command, [], {
105+
const process = spawn(processedCommand, [], {
94106
shell: true,
95107
cwd: workingDirectory,
96108
});
97109

98110
const processState: ProcessState = {
99-
command,
111+
command: processedCommand,
100112
process,
101113
stdout: [],
102114
stderr: [],
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { describe, it, expect, vi } from 'vitest';
2+
3+
import { getPlatformNewline, formatGitHubText } from './github.js';
4+
5+
describe('GitHub utilities', () => {
6+
describe('getPlatformNewline', () => {
7+
it('should return \\n for non-Windows platforms', () => {
8+
// Mock process.platform to be 'darwin'
9+
vi.stubGlobal('process', { ...process, platform: 'darwin' });
10+
expect(getPlatformNewline()).toBe('\\n');
11+
12+
// Mock process.platform to be 'linux'
13+
vi.stubGlobal('process', { ...process, platform: 'linux' });
14+
expect(getPlatformNewline()).toBe('\\n');
15+
});
16+
17+
it('should return \\r\\n for Windows platform', () => {
18+
// Mock process.platform to be 'win32'
19+
vi.stubGlobal('process', { ...process, platform: 'win32' });
20+
expect(getPlatformNewline()).toBe('\\r\\n');
21+
});
22+
});
23+
24+
describe('formatGitHubText', () => {
25+
it('should replace newlines with platform-specific escape sequences', () => {
26+
// Mock process.platform to be 'darwin'
27+
vi.stubGlobal('process', { ...process, platform: 'darwin' });
28+
29+
const input = 'Hello\nWorld\nThis is a test';
30+
const expected = 'Hello\\nWorld\\nThis is a test';
31+
32+
expect(formatGitHubText(input)).toBe(expected);
33+
});
34+
35+
it('should handle Windows newlines correctly', () => {
36+
// Mock process.platform to be 'win32'
37+
vi.stubGlobal('process', { ...process, platform: 'win32' });
38+
39+
const input = 'Hello\nWorld\nThis is a test';
40+
const expected = 'Hello\\r\\nWorld\\r\\nThis is a test';
41+
42+
expect(formatGitHubText(input)).toBe(expected);
43+
});
44+
});
45+
});

packages/agent/src/utils/github.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/**
2+
* Utility functions for GitHub CLI integration
3+
*/
4+
5+
/**
6+
* Returns the appropriate newline character sequence for the current platform
7+
* when used in GitHub CLI commands
8+
*
9+
* @returns The platform-specific newline escape sequence
10+
*/
11+
export function getPlatformNewline(): string {
12+
// Check if we're on Windows
13+
if (process.platform === 'win32') {
14+
return '\\r\\n'; // Windows uses CRLF
15+
}
16+
return '\\n'; // Unix-based systems (Linux, macOS) use LF
17+
}
18+
19+
/**
20+
* Formats text for use in GitHub CLI commands by ensuring
21+
* newlines are properly escaped for the current platform
22+
*
23+
* @param text The text to format
24+
* @returns Formatted text with proper newline escaping
25+
*/
26+
export function formatGitHubText(text: string): string {
27+
const platformNewline = getPlatformNewline();
28+
29+
// Replace any literal newlines with platform-specific escape sequences
30+
return text.replace(/\n/g, platformNewline);
31+
}

0 commit comments

Comments
 (0)