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
56 changes: 56 additions & 0 deletions packages/happy-cli/src/claude/appPromptDedupe.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createAppPromptDedupe } from './appPromptDedupe';

describe('createAppPromptDedupe', () => {
afterEach(() => {
vi.useRealTimers();
});

it('consumes an exact match exactly once', () => {
const dedupe = createAppPromptDedupe();
dedupe.record('hello');
expect(dedupe.consume('hello')).toBe(true);
// Consumed entries are removed, so an identical terminal-typed
// prompt still gets forwarded afterwards.
expect(dedupe.consume('hello')).toBe(false);
});

it('matches when the SDK echoes the prompt with a trailing newline', () => {
// Regression: the app sends "fix it" and the JSONL scanner sees
// "fix it\n" — the exact-match dedupe used to miss this and the
// app-sent prompt was persisted twice on the server.
const dedupe = createAppPromptDedupe();
dedupe.record('fix it');
expect(dedupe.consume('fix it\n')).toBe(true);
});

it('normalizes whitespace on both sides', () => {
const dedupe = createAppPromptDedupe();
dedupe.record(' spaced prompt ');
expect(dedupe.consume('\tspaced prompt\n')).toBe(true);
});

it('does not consume text that was never recorded', () => {
const dedupe = createAppPromptDedupe();
dedupe.record('one');
expect(dedupe.consume('two')).toBe(false);
});

it('expires entries older than the max age', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
const dedupe = createAppPromptDedupe(5 * 60 * 1000);
dedupe.record('stale');
vi.setSystemTime(new Date('2026-01-01T00:06:00Z'));
expect(dedupe.consume('stale')).toBe(false);
});

it('keeps entries within the max age', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
const dedupe = createAppPromptDedupe(5 * 60 * 1000);
dedupe.record('fresh');
vi.setSystemTime(new Date('2026-01-01T00:04:00Z'));
expect(dedupe.consume('fresh')).toBe(true);
});
});
46 changes: 46 additions & 0 deletions packages/happy-cli/src/claude/appPromptDedupe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/**
* Ring buffer of user prompts that just arrived from the app, used by the
* remote-mode session scanner to avoid double-forwarding.
*
* The scanner walks the on-disk Claude JSONL looking for prompts that landed
* in the file but never reached the server — i.e. the ones the user typed in
* a `claude --resume <id>` terminal sitting alongside a Happy session.
* App-sent prompts also land in the JSONL once the SDK writes them, so they
* would be forwarded twice without this dedupe.
*
* Entries match by content within a short time window; entries older than
* `maxAgeMs` roll off so unrelated future prompts with identical text still
* get through from the terminal side.
*
* Prompts are trimmed before comparison: the SDK writes the prompt to the
* JSONL with a trailing newline while the app delivers it without one, so an
* exact-match dedupe misses and the app-sent prompt is persisted twice.
*/
export function createAppPromptDedupe(maxAgeMs: number = 5 * 60 * 1000) {
const recentAppPrompts: Array<{ text: string; addedAt: number }> = [];

const record = (text: string) => {
const now = Date.now();
recentAppPrompts.push({ text: text.trim(), addedAt: now });
const cutoff = now - maxAgeMs;
while (recentAppPrompts.length > 0 && recentAppPrompts[0].addedAt < cutoff) {
recentAppPrompts.shift();
}
};

const consume = (text: string): boolean => {
const normalized = text.trim();
const cutoff = Date.now() - maxAgeMs;
for (let i = 0; i < recentAppPrompts.length; i++) {
const entry = recentAppPrompts[i];
if (entry.addedAt < cutoff) continue;
if (entry.text === normalized) {
recentAppPrompts.splice(i, 1);
return true;
}
}
return false;
};

return { record, consume };
}
41 changes: 7 additions & 34 deletions packages/happy-cli/src/claude/runClaude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { AgentGoalStatus, AgentState, Metadata } from '@/api/types';
import packageJson from '../../package.json';
import { Credentials, readSettings } from '@/persistence';
import { EnhancedMode, PermissionMode } from './loop';
import { createAppPromptDedupe } from './appPromptDedupe';
import { MessageQueue2 } from '@/utils/MessageQueue2';
import { hashObject } from '@/utils/deterministicJson';
import { parseSpecialCommand } from '@/parsers/specialCommands';
Expand Down Expand Up @@ -342,38 +343,10 @@ export async function runClaude(credentials: Credentials, options: StartOptions
session.updateMetadata((meta) => ({ ...meta, claudeSessionId: forkClaudeSessionId }));
}

// Ring buffer of user prompts that just arrived from the app via the
// legacy `sentFrom: 'web'` channel. The remote-mode session scanner
// (started below) walks the on-disk Claude JSONL looking for prompts
// that landed in the file but never reached the server — i.e. the
// ones the user typed in a `claude --resume <id>` terminal sitting
// alongside this Happy session. App-sent prompts also land in the
// JSONL once the SDK writes them, so we'd double-forward them
// without this dedupe. Match by content within a short time window;
// entries older than 5 minutes roll off so unrelated future prompts
// with identical text still get through from the terminal side.
const recentAppPromptsMaxAgeMs = 5 * 60 * 1000;
const recentAppPrompts: Array<{ text: string; addedAt: number }> = [];
const recordAppPrompt = (text: string) => {
const now = Date.now();
recentAppPrompts.push({ text, addedAt: now });
const cutoff = now - recentAppPromptsMaxAgeMs;
while (recentAppPrompts.length > 0 && recentAppPrompts[0].addedAt < cutoff) {
recentAppPrompts.shift();
}
};
const consumeAppPrompt = (text: string): boolean => {
const cutoff = Date.now() - recentAppPromptsMaxAgeMs;
for (let i = 0; i < recentAppPrompts.length; i++) {
const entry = recentAppPrompts[i];
if (entry.addedAt < cutoff) continue;
if (entry.text === text) {
recentAppPrompts.splice(i, 1);
return true;
}
}
return false;
};
// Dedupe ring buffer for app-sent prompts, consumed by the remote-mode
// session scanner below. Prompts are trimmed before comparison — see
// appPromptDedupe.ts for the full rationale and matching semantics.
const appPromptDedupe = createAppPromptDedupe();

let currentRunMode: 'local' | 'remote' = options.startingMode ?? 'local';
let latestClaudeGoalStatus: AgentGoalStatus | null = null;
Expand Down Expand Up @@ -457,7 +430,7 @@ export async function runClaude(credentials: Credentials, options: StartOptions
if (content.trim().length === 0) return;
// App-sent prompts will show up here because the SDK
// writes them to the JSONL — dedupe by content.
if (consumeAppPrompt(content)) return;
if (appPromptDedupe.consume(content)) return;
session.sendClaudeSessionMessage(raw);
},
onTranscriptEvent: updateClaudeGoalState,
Expand Down Expand Up @@ -659,7 +632,7 @@ export async function runClaude(credentials: Credentials, options: StartOptions
// it later — the SDK is about to write this same text to disk
// with a real Claude uuid, and we don't want to re-forward it.
if (message?.content?.text) {
recordAppPrompt(message.content.text);
appPromptDedupe.record(message.content.text);
}

// Claim every file attachment that arrived strictly before this text.
Expand Down