Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -93,3 +93,4 @@ apps/*/tooling/node-deps/node_modules/
# Vitest / Browser testing
.vitest-attachments/
**/__screenshots__/
.amp/plugins/emdash-hook.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Pty, PtyExitInfo } from '@main/core/pty/pty';
import { events } from '@main/lib/events';
import { conversationInitialPromptInjectionFailedChannel } from '@shared/core/conversations/conversationEvents';
import type { Conversation } from '@shared/core/conversations/conversations';
import { scheduleInitialPromptInjection } from './keystroke-injection';

vi.mock('@main/lib/events', () => ({
events: { emit: vi.fn() },
}));

function makeConversation(providerId: Conversation['providerId']): Conversation {
return {
id: 'conv-1',
Expand Down Expand Up @@ -47,13 +53,14 @@ function makePty(): {
describe('scheduleInitialPromptInjection', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.mocked(events.emit).mockClear();
});

afterEach(() => {
vi.useRealTimers();
});

it('injects after PTY output goes quiet', () => {
it('injects after provider output goes quiet', () => {
const { pty, write, emitData } = makePty();
scheduleInitialPromptInjection({
pty,
Expand All @@ -62,7 +69,7 @@ describe('scheduleInitialPromptInjection', () => {
isResuming: false,
});

emitData('booting...');
emitData('Hermes booting...');
vi.advanceTimersByTime(200);
emitData('still booting...');
expect(write).not.toHaveBeenCalled();
Expand All @@ -71,7 +78,7 @@ describe('scheduleInitialPromptInjection', () => {
expect(write).toHaveBeenCalledExactlyOnceWith('Fix the bug\r');
});

it('falls back to a max wait when no output ever arrives', () => {
it('does not inject when no provider output ever arrives', () => {
const { pty, write } = makePty();
scheduleInitialPromptInjection({
pty,
Expand All @@ -81,7 +88,7 @@ describe('scheduleInitialPromptInjection', () => {
});

vi.advanceTimersByTime(15_000);
expect(write).toHaveBeenCalledExactlyOnceWith('Fix the bug\r');
expect(write).not.toHaveBeenCalled();
});

it('wraps multi-line prompts in bracketed paste sequences', () => {
Expand All @@ -93,11 +100,55 @@ describe('scheduleInitialPromptInjection', () => {
isResuming: false,
});

emitData('ready');
emitData('Hermes ready');
vi.advanceTimersByTime(900);
expect(write).toHaveBeenCalledExactlyOnceWith('\x1b[200~line one\nline two\x1b[201~\r');
});

it('does not inject into generic shell output', () => {
const { pty, write, emitData } = makePty();
scheduleInitialPromptInjection({
pty,
conversation: makeConversation('hermes'),
initialPrompt: 'Fix the bug',
isResuming: false,
});

emitData('Last login: Fri Jun 12\n% ');
vi.advanceTimersByTime(20_000);
expect(write).not.toHaveBeenCalled();
});

it('cancels injection when shell failures appear before provider output', () => {
const { pty, write, emitData } = makePty();
scheduleInitialPromptInjection({
pty,
conversation: makeConversation('hermes'),
initialPrompt: 'Fix the bug',
isResuming: false,
});

emitData('zsh:1: command not found: hermes\n% ');
emitData('Hermes ready');
vi.advanceTimersByTime(900);
expect(write).not.toHaveBeenCalled();
});

it('cancels injection when shell failure output is split across chunks', () => {
const { pty, write, emitData } = makePty();
scheduleInitialPromptInjection({
pty,
conversation: makeConversation('hermes'),
initialPrompt: 'Fix the bug',
isResuming: false,
});

emitData('zsh: ');
emitData('command not found: hermes\n% ');
vi.advanceTimersByTime(900);
expect(write).not.toHaveBeenCalled();
});

it('does nothing for OpenCode because its initial prompt is passed with --prompt', () => {
const { pty, write, emitData } = makePty();
scheduleInitialPromptInjection({
Expand Down Expand Up @@ -154,7 +205,7 @@ describe('scheduleInitialPromptInjection', () => {
expect(write).not.toHaveBeenCalled();
});

it('cancels injection when the PTY exits before idle', () => {
it('emits a user-visible failure event when the PTY exits before ready output', () => {
const { pty, write, emitData, emitExit } = makePty();
scheduleInitialPromptInjection({
pty,
Expand All @@ -167,5 +218,11 @@ describe('scheduleInitialPromptInjection', () => {
emitExit();
vi.advanceTimersByTime(20_000);
expect(write).not.toHaveBeenCalled();
expect(events.emit).toHaveBeenCalledWith(conversationInitialPromptInjectionFailedChannel, {
conversationId: 'conv-1',
taskId: 'task-1',
projectId: 'proj-1',
providerId: 'hermes',
});
});
});
Original file line number Diff line number Diff line change
@@ -1,13 +1,26 @@
import type { Pty } from '@main/core/pty/pty';
import { events } from '@main/lib/events';
import { log } from '@main/lib/logger';
import { getProvider } from '@shared/core/agents/agent-provider-registry';
import { conversationInitialPromptInjectionFailedChannel } from '@shared/core/conversations/conversationEvents';
import type { Conversation } from '@shared/core/conversations/conversations';
import { buildPromptInjectionPayload } from '@shared/prompt-injection';

// Inject only after the TUI has produced output and stayed idle for a beat;
// fixed delays race the agent's startup (auth, sync, model load).
const QUIET_PERIOD_MS = 800;
const MAX_WAIT_MS = 15_000;

const KEYSTROKE_READY_OUTPUT: Partial<Record<Conversation['providerId'], RegExp>> = {
grok: /\b(grok|xai|x\.ai)\b/i,
hermes: /\bhermes\b/i,
kimi: /\bkimi\b/i,
jules: /\bjules\b/i,
letta: /\bletta\b/i,
};

const SHELL_FAILURE_OUTPUT =
/(?:^|\n)(?:zsh|bash|sh|fish)(?::\d+)?(?::\s+|\s+)(?:command not found|parse error|no such file or directory)\b/i;
const OUTPUT_BUFFER_MAX = 4096;

export function scheduleInitialPromptInjection(args: {
pty: Pty;
Expand All @@ -21,20 +34,24 @@ export function scheduleInitialPromptInjection(args: {
const provider = getProvider(args.conversation.providerId);
if (!provider?.useKeystrokeInjection) return;

const readyOutput = KEYSTROKE_READY_OUTPUT[args.conversation.providerId];
if (!readyOutput) return;

const payload = buildPromptInjectionPayload({
providerId: args.conversation.providerId,
text: args.initialPrompt,
});

let injected = false;
let sawAnyOutput = false;
let sawReadyOutput = false;
let outputBuffer = '';
let quietTimer: ReturnType<typeof setTimeout> | null = null;

const inject = () => {
if (injected) return;
injected = true;
if (quietTimer) clearTimeout(quietTimer);
clearTimeout(maxWaitTimer);
try {
const submitSequence = provider.keystrokeSubmitSequence ?? '\r';
const submitDelayMs = provider.keystrokeSubmitDelayMs;
Expand All @@ -53,11 +70,21 @@ export function scheduleInitialPromptInjection(args: {
}
};

const maxWaitTimer = setTimeout(inject, MAX_WAIT_MS);

args.pty.onData(() => {
args.pty.onData((data) => {
if (injected) return;
sawAnyOutput = true;
outputBuffer = `${outputBuffer}${data}`.slice(-OUTPUT_BUFFER_MAX);
if (SHELL_FAILURE_OUTPUT.test(outputBuffer)) {
injected = true;
if (quietTimer) clearTimeout(quietTimer);
log.warn('ConversationProvider: shell output detected before initial prompt injection', {
providerId: args.conversation.providerId,
conversationId: args.conversation.id,
});
return;
}
sawReadyOutput = sawReadyOutput || readyOutput.test(outputBuffer);
if (!sawReadyOutput) return;
if (quietTimer) clearTimeout(quietTimer);
quietTimer = setTimeout(inject, QUIET_PERIOD_MS);
});
Expand All @@ -66,13 +93,21 @@ export function scheduleInitialPromptInjection(args: {
const promptWasInjected = injected;
injected = true;
if (quietTimer) clearTimeout(quietTimer);
clearTimeout(maxWaitTimer);
if (!promptWasInjected) {
log.warn('ConversationProvider: PTY exited before initial prompt could be injected', {
providerId: args.conversation.providerId,
conversationId: args.conversation.id,
sawAnyOutput,
sawReadyOutput,
});
if (!sawReadyOutput) {
events.emit(conversationInitialPromptInjectionFailedChannel, {
conversationId: args.conversation.id,
taskId: args.conversation.taskId,
projectId: args.conversation.projectId,
providerId: args.conversation.providerId,
});
}
}
});
}
Loading
Loading