From e56a3d14c36f436ed6860873b6dd9d9c1bc50f5c Mon Sep 17 00:00:00 2001 From: Christophe Gatti Date: Tue, 28 Apr 2026 22:31:28 +0200 Subject: [PATCH 1/4] feat(cli): improve SSH interactivity detection via SSH_TTY and SSH_CONNECTION --- src/main.tsx | 16 ++++--- src/utils/interactivity.test.ts | 79 +++++++++++++++++++++++++++++++++ src/utils/interactivity.ts | 26 +++++++++++ 3 files changed, 114 insertions(+), 7 deletions(-) create mode 100644 src/utils/interactivity.test.ts create mode 100644 src/utils/interactivity.ts diff --git a/src/main.tsx b/src/main.tsx index a64b465c40..8f5671a509 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -158,6 +158,7 @@ import { getCwd } from 'src/utils/cwd.js'; import { logForDebugging, setHasFormattedOutput } from 'src/utils/debug.js'; import { errorMessage, getErrnoCode, isENOENT, TeleportOperationError, toError } from 'src/utils/errors.js'; import { getFsImplementation, safeResolvePath } from 'src/utils/fsOperations.js'; +import { isInteractiveSession } from 'src/utils/interactivity.js'; import { gracefulShutdown, gracefulShutdownSync } from 'src/utils/gracefulShutdown.js'; import { setAllHookEventsEnabled } from 'src/utils/hooks/hookEvents.js'; import { refreshModelCapabilities } from 'src/utils/model/modelCapabilities.js'; @@ -583,6 +584,7 @@ const _pendingSSH: PendingSSH | undefined = feature('SSH_REMOTE') ? { local: false, extraCliArgs: [] } : undefined; + export async function main() { profileCheckpoint('main_function_start'); @@ -795,13 +797,14 @@ export async function main() { } } - // Check for -p/--print and --init-only flags early to set isInteractiveSession before init() + // Check for interactivity early to set isInteractiveSession before init() // This is needed because telemetry initialization calls auth functions that need this flag - const cliArgs = process.argv.slice(2); - const hasPrintFlag = cliArgs.includes('-p') || cliArgs.includes('--print'); - const hasInitOnlyFlag = cliArgs.includes('--init-only'); - const hasSdkUrl = cliArgs.some(arg => arg.startsWith('--sdk-url')); - const isNonInteractive = hasPrintFlag || hasInitOnlyFlag || hasSdkUrl || !process.stdout.isTTY; + const isInteractive = isInteractiveSession({ + stdoutIsTTY: process.stdout.isTTY, + args: process.argv.slice(2), + env: process.env, + }); + const isNonInteractive = !isInteractive; // Stop capturing early input for non-interactive modes if (isNonInteractive) { @@ -809,7 +812,6 @@ export async function main() { } // Set simplified tracking fields - const isInteractive = !isNonInteractive; setIsInteractive(isInteractive); // Initialize entrypoint based on mode - needs to be set before any event is logged diff --git a/src/utils/interactivity.test.ts b/src/utils/interactivity.test.ts new file mode 100644 index 0000000000..fdd526cf06 --- /dev/null +++ b/src/utils/interactivity.test.ts @@ -0,0 +1,79 @@ +import { expect, test, describe } from 'bun:test' +import { isInteractiveSession } from './interactivity.js' + +describe('isInteractiveSession', () => { + test('returns true when stdout is TTY', () => { + expect( + isInteractiveSession({ + stdoutIsTTY: true, + args: [], + env: {}, + }), + ).toBe(true) + }) + + test('returns false when stdout is not TTY and no SSH env vars', () => { + expect( + isInteractiveSession({ + stdoutIsTTY: false, + args: [], + env: {}, + }), + ).toBe(false) + }) + + test('returns true when in SSH session even if stdout is not TTY (SSH_TTY)', () => { + expect( + isInteractiveSession({ + stdoutIsTTY: false, + args: [], + env: { SSH_TTY: '/dev/pts/0' }, + }), + ).toBe(true) + }) + + test('returns false when in SSH session without TTY allocation (SSH_CONNECTION only)', () => { + // Regression test for piped-stdin-over-ssh case + expect( + isInteractiveSession({ + stdoutIsTTY: false, + args: [], + env: { SSH_CONNECTION: '192.168.1.1 56789 192.168.1.100 22' }, + }), + ).toBe(false) + }) + + test('returns false when explicit non-interactive flags are present even with SSH', () => { + expect( + isInteractiveSession({ + stdoutIsTTY: true, + args: ['-p'], + env: { SSH_TTY: '/dev/pts/0' }, + }), + ).toBe(false) + + expect( + isInteractiveSession({ + stdoutIsTTY: true, + args: ['--print'], + env: { SSH_TTY: '/dev/pts/0' }, + }), + ).toBe(false) + + expect( + isInteractiveSession({ + stdoutIsTTY: true, + args: ['--init-only'], + env: { SSH_TTY: '/dev/pts/0' }, + }), + ).toBe(false) + + expect( + isInteractiveSession({ + stdoutIsTTY: true, + args: ['--sdk-url=ws://localhost'], + env: { SSH_TTY: '/dev/pts/0' }, + }), + ).toBe(false) + }) +}) diff --git a/src/utils/interactivity.ts b/src/utils/interactivity.ts new file mode 100644 index 0000000000..ba2e8ac7d4 --- /dev/null +++ b/src/utils/interactivity.ts @@ -0,0 +1,26 @@ +/** + * Determines if the current session should be treated as interactive. + * Robustly handles SSH sessions which might not report TTY status accurately. + */ +export function isInteractiveSession(options: { + stdoutIsTTY: boolean; + args: string[]; + env: NodeJS.ProcessEnv; +}): boolean { + const { stdoutIsTTY, args, env } = options; + + // Explicit non-interactive flags + const hasPrintFlag = args.includes('-p') || args.includes('--print'); + const hasInitOnlyFlag = args.includes('--init-only'); + const hasSdkUrl = args.some(arg => arg.startsWith('--sdk-url')); + + if (hasPrintFlag || hasInitOnlyFlag || hasSdkUrl) { + return false; + } + + // Robust interactivity check: consider SSH sessions as interactive even if isTTY is unreliable. + // Standard SSH environment variable SSH_TTY (path to tty) is only set when a pty is allocated. + const isSSH = Boolean(env.SSH_TTY); + + return stdoutIsTTY || isSSH; +} From b8b5b09b9efb24f101ba669ffe9f576a648a1516 Mon Sep 17 00:00:00 2001 From: Christophe Gatti Date: Wed, 20 May 2026 23:22:27 +0200 Subject: [PATCH 2/4] feat(models): add support for Gemma 4 31B --- src/integrations/models/openai-compatible-alias.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/integrations/models/openai-compatible-alias.ts b/src/integrations/models/openai-compatible-alias.ts index bfa1b6b1b7..834d88bb53 100644 --- a/src/integrations/models/openai-compatible-alias.ts +++ b/src/integrations/models/openai-compatible-alias.ts @@ -86,6 +86,7 @@ const aliasModels: readonly AliasModel[] = [ ['google/gemma-3-27b-it', 'Google Gemma 3 27B IT', 131_072, 16_384], ['google/gemma-3-12b-it', 'Google Gemma 3 12B IT', 131_072, 16_384], ['google/gemma-3-4b-it', 'Google Gemma 3 4B IT', 131_072], + ['google/gemma-4-31b-it', 'Google Gemma 4 31B IT', 262_144, 16_384], ['deepseek-ai/deepseek-r1', 'DeepSeek R1', 128_000, 32_768], ['deepseek-ai/deepseek-v3', 'DeepSeek V3', 128_000, 32_768], ['deepseek-ai/deepseek-v3.2', 'DeepSeek V3.2', 128_000, 32_768], @@ -115,6 +116,7 @@ const aliasModels: readonly AliasModel[] = [ ['deepseek-r1:14b', 'DeepSeek R1 14B', 65_536, 8_192], ['phi4:14b', 'Phi 4 14B', 16_384, 4_096], ['gemma2:27b', 'Gemma 2 27B', 8_192, 4_096], + ['gemma4:31b', 'Gemma 4 31B', 262_144, 16_384], ['codellama:13b', 'Code Llama 13B', 16_384, 4_096], ['qwen3:8b', 'Qwen 3 8B', 128_000, 8_192], ['qwen3-max-2026-01-23', 'Qwen 3 Max 2026-01-23', 262_144, 32_768], From 47300fa4ad6336fc6028f8f5d94fac2a256e3f67 Mon Sep 17 00:00:00 2001 From: Christophe Gatti Date: Wed, 20 May 2026 23:56:05 +0200 Subject: [PATCH 3/4] feat(query): robust multi-lingual and structural continuation nudge --- src/__tests__/bugfixes.test.ts | 2 +- src/query.ts | 50 ++++++++++++++++++++++------------ 2 files changed, 33 insertions(+), 19 deletions(-) diff --git a/src/__tests__/bugfixes.test.ts b/src/__tests__/bugfixes.test.ts index bad906d798..f6ab43a035 100644 --- a/src/__tests__/bugfixes.test.ts +++ b/src/__tests__/bugfixes.test.ts @@ -109,7 +109,7 @@ describe('Agent loop continuation nudge', () => { const content = await file('query.ts').text() expect(content).toContain( - 'Continue with the task. Use the appropriate tools to proceed.', + 'Continue with the task. If you were interrupted, resume your thought. Otherwise, use the appropriate tools to proceed to the next step.', ) }) }) diff --git a/src/query.ts b/src/query.ts index 2cbc394860..2b65c18c2b 100644 --- a/src/query.ts +++ b/src/query.ts @@ -1462,34 +1462,48 @@ async function* queryLoop( // Tightened patterns: require explicit action verbs and exclude // common explanatory phrasing to reduce false positives. const continuationSignals = [ - // Only match "so now I/let me/we" followed by an action verb + // English: Only match "so now I/let me/we" followed by an action verb /\bso now (i|let me|we) (need to|have to|should|must|will) (do|create|write|edit|update|fix|implement|add|run|check|make|build|set up)\b/, - // "now I'll" + action (not "now I'll explain" etc.) /\bnow i('ll| will) (do|create|write|edit|update|fix|implement|add|run|check|make|build|set up|go|proceed)\b/, - // "let me" + action (not "let me think/explain/show") /\blet me (go ahead and |now )?(do|create|write|edit|update|fix|implement|add|run|check|make|build|set up|proceed)\b/, - // "I'll/I need to/I have to" + action, only if message is short (<80 chars) - ...(lastText.length < 80 - ? [/\b(i('ll| will| need to| have to| must) (now )?(do|create|write|edit|update|fix|implement|add|run|check|make|build|set up))\b/] - : []), - // "time to" + action /\btime to (do|create|write|edit|update|fix|implement|add|run|check|make|build|get started|begin)\b/, - // "next, I'll/let me" + action, only if message is short - ...(lastText.length < 80 - ? [/\bnext,?\s+(i('ll| will)|let me|i need to) (do|create|write|edit|update|fix|implement|add|run|check|make|build)\b/] - : []), + // French: Support for common continuation phrasing + /\b(je passe (à|au)|ensuite|l'étape suivante|je continue|au suivant)\b/, + /\bje vais (maintenant )?(faire|créer|écrire|modifier|ajouter|tester|vérifier|lancer|exécuter|procéder)\b/, + // Universal: Sentence ending with a colon indicates intent to list/act + /:$/, ] - // Don't nudge if the text contains completion markers + // Universal: Truncation detection. If the message ends without terminal punctuation + // and has no tool calls, it's likely a token limit truncation. + const hasTerminalPunctuation = /[.!??"'`]\s*$/.test(lastText) + const isPossiblyTruncated = !hasTerminalPunctuation && lastText.length > 20 + + // Don't nudge if the text contains completion markers, + // UNLESS a strong continuation signal or truncation is detected at the very end. const completionMarkers = /\b(done|finished|completed|complete|summary|that's all|that is all|all set|hope this helps|let me know if)\b/ - if (completionMarkers.test(lastText)) { - // Model signaled completion — don't nudge - } else if (continuationSignals.some(re => re.test(lastText))) { + const hasCompletionMarker = completionMarkers.test(lastText) + + // Check if continuation signals match in the last 80 characters (late intent) + const lateText = lastText.slice(-80) + const hasLateContinuationSignal = continuationSignals.some(re => re.test(lateText)) + + let shouldNudge = false + if (isPossiblyTruncated) { + shouldNudge = true + } else if (hasLateContinuationSignal) { + shouldNudge = true + } else if (continuationSignals.some(re => re.test(lastText)) && !hasCompletionMarker) { + shouldNudge = true + } + + if (shouldNudge) { + const nudgeReason = isPossiblyTruncated ? 'possible truncation' : 'continuation signal' logForDebugging( - `Continuation nudge triggered (${state.continuationNudgeCount + 1}/${MAX_CONTINUATION_NUDGES}): model said "${lastText.slice(-120)}" without tool calls`, + `Continuation nudge triggered (${state.continuationNudgeCount + 1}/${MAX_CONTINUATION_NUDGES}): ${nudgeReason} detected in "${lastText.slice(-120)}" without tool calls`, ) const nudge = createUserMessage({ - content: 'Continue with the task. Use the appropriate tools to proceed.', + content: 'Continue with the task. If you were interrupted, resume your thought. Otherwise, use the appropriate tools to proceed to the next step.', isMeta: true, }) const next: State = { From eac3537dd8ea537bdac6134486b3be9e825cb055 Mon Sep 17 00:00:00 2001 From: Christophe Gatti Date: Thu, 21 May 2026 08:38:48 +0200 Subject: [PATCH 4/4] fix(query): refine continuation nudge logic to avoid false positives --- src/__tests__/bugfixes.test.ts | 52 +++++++++++--- src/query.ts | 45 ++---------- src/utils/continuation.ts | 128 +++++++++++++++++++++++++++++++++ 3 files changed, 175 insertions(+), 50 deletions(-) create mode 100644 src/utils/continuation.ts diff --git a/src/__tests__/bugfixes.test.ts b/src/__tests__/bugfixes.test.ts index f6ab43a035..8924fb00e5 100644 --- a/src/__tests__/bugfixes.test.ts +++ b/src/__tests__/bugfixes.test.ts @@ -86,23 +86,53 @@ describe('Session timeout fix', () => { // Fix 3: Agent loop continuation nudge // --------------------------------------------------------------------------- describe('Agent loop continuation nudge', () => { - test('query.ts has continuation signal detection', async () => { + test('continuation logic has been moved to utility', async () => { const content = await file('query.ts').text() - - expect(content).toContain('continuationSignals') - expect(content).toContain('Continuation nudge triggered') - expect(content).toContain('continuation_nudge') + // query.ts should now call the utility + expect(content).toContain('analyzeContinuationIntent') }) - test('continuation signals include tightened patterns', async () => { - const content = await file('query.ts').text() + test('continuation.ts has robust patterns', async () => { + const content = await file('utils/continuation.ts').text() + expect(content).toContain('CONTINUATION_SIGNALS') + expect(content).toContain('COMPLETION_MARKERS') // Should detect tightened patterns requiring explicit action verbs expect(content).toMatch(/so now \(i\|let me\|we\)/) - expect(content).toContain('completionMarkers') - expect(content).toContain('MAX_CONTINUATION_NUDGES') - // Verify the nudge counter guard exists - expect(content).toMatch(/continuationNudgeCount\s*<\s*MAX_CONTINUATION_NUDGES/) + }) + + test('analyzeContinuationIntent behavior follows project standards', async () => { + const { analyzeContinuationIntent } = await import('../utils/continuation.js') + + // Transition intent detected (requires explicit action verb or transition phrase) + expect(analyzeContinuationIntent("So now I will start task 2").shouldNudge).toBe(true) + expect(analyzeContinuationIntent("I will now do the following").shouldNudge).toBe(true) + + // Completion marker suppresses nudge + expect(analyzeContinuationIntent("Task finished").shouldNudge).toBe(false) + + // Punctuation-less completion suppresses nudge (Reviewer Feedback) + expect(analyzeContinuationIntent("The analysis is complete and no code changes are needed here").shouldNudge).toBe(false) + expect(analyzeContinuationIntent("I changed package.json and src/query.ts and added tests").shouldNudge).toBe(false) + expect(analyzeContinuationIntent("Updated src/query.ts and added coverage in bugfixes.test.ts").shouldNudge).toBe(false) + expect(analyzeContinuationIntent("This should be ready after the latest test updates").shouldNudge).toBe(false) + + // Mixed Intent: Late continuation survives earlier completion (Reviewer Feedback) + expect(analyzeContinuationIntent("Task 1 is done. Let me update the status.").shouldNudge).toBe(true) + expect(analyzeContinuationIntent("Task 1 finished. I will now run tests.").shouldNudge).toBe(true) + expect(analyzeContinuationIntent("Analysis complete. Now I will edit src/query.ts").shouldNudge).toBe(true) + expect(analyzeContinuationIntent("No issues in the first file. I will now inspect the next one.").shouldNudge).toBe(true) + + // Structural truncation survives earlier completion (Reviewer Feedback) + expect(analyzeContinuationIntent("Setup is complete. Here is the code:\n```typescript\nfunction run() {").shouldNudge).toBe(true) + expect(analyzeContinuationIntent("Task complete. Please inspect (src/query.ts").shouldNudge).toBe(true) + expect(analyzeContinuationIntent("The analysis is done and now I am editing files and").shouldNudge).toBe(true) + + // Structural truncation detection (Supreme Logic) + expect(analyzeContinuationIntent("I am currently updating the following files and").shouldNudge).toBe(true) + expect(analyzeContinuationIntent("Please check the results in (src/query.ts").shouldNudge).toBe(true) + expect(analyzeContinuationIntent("The plan is as follows:").shouldNudge).toBe(true) + expect(analyzeContinuationIntent("Here is the code:\n```typescript\nfunction test() {").shouldNudge).toBe(true) }) test('nudge creates a meta user message to continue', async () => { diff --git a/src/query.ts b/src/query.ts index 2b65c18c2b..a6ce40521e 100644 --- a/src/query.ts +++ b/src/query.ts @@ -53,6 +53,7 @@ import { createToolUseSummaryMessage, createMicrocompactBoundaryMessage, } from './utils/messages.js' +import { analyzeContinuationIntent } from './utils/continuation.js' import { generateToolUseSummary } from './services/toolUseSummary/toolUseSummaryGenerator.js' import { prependUserContext, appendSystemContext } from './utils/api.js' import { @@ -1459,51 +1460,17 @@ async function* queryLoop( .join(' ') .toLowerCase() - // Tightened patterns: require explicit action verbs and exclude - // common explanatory phrasing to reduce false positives. - const continuationSignals = [ - // English: Only match "so now I/let me/we" followed by an action verb - /\bso now (i|let me|we) (need to|have to|should|must|will) (do|create|write|edit|update|fix|implement|add|run|check|make|build|set up)\b/, - /\bnow i('ll| will) (do|create|write|edit|update|fix|implement|add|run|check|make|build|set up|go|proceed)\b/, - /\blet me (go ahead and |now )?(do|create|write|edit|update|fix|implement|add|run|check|make|build|set up|proceed)\b/, - /\btime to (do|create|write|edit|update|fix|implement|add|run|check|make|build|get started|begin)\b/, - // French: Support for common continuation phrasing - /\b(je passe (à|au)|ensuite|l'étape suivante|je continue|au suivant)\b/, - /\bje vais (maintenant )?(faire|créer|écrire|modifier|ajouter|tester|vérifier|lancer|exécuter|procéder)\b/, - // Universal: Sentence ending with a colon indicates intent to list/act - /:$/, - ] - - // Universal: Truncation detection. If the message ends without terminal punctuation - // and has no tool calls, it's likely a token limit truncation. - const hasTerminalPunctuation = /[.!??"'`]\s*$/.test(lastText) - const isPossiblyTruncated = !hasTerminalPunctuation && lastText.length > 20 - - // Don't nudge if the text contains completion markers, - // UNLESS a strong continuation signal or truncation is detected at the very end. - const completionMarkers = /\b(done|finished|completed|complete|summary|that's all|that is all|all set|hope this helps|let me know if)\b/ - const hasCompletionMarker = completionMarkers.test(lastText) - - // Check if continuation signals match in the last 80 characters (late intent) - const lateText = lastText.slice(-80) - const hasLateContinuationSignal = continuationSignals.some(re => re.test(lateText)) - - let shouldNudge = false - if (isPossiblyTruncated) { - shouldNudge = true - } else if (hasLateContinuationSignal) { - shouldNudge = true - } else if (continuationSignals.some(re => re.test(lastText)) && !hasCompletionMarker) { - shouldNudge = true - } + const { shouldNudge, reason: nudgeReason } = analyzeContinuationIntent( + lastText, + ) if (shouldNudge) { - const nudgeReason = isPossiblyTruncated ? 'possible truncation' : 'continuation signal' logForDebugging( `Continuation nudge triggered (${state.continuationNudgeCount + 1}/${MAX_CONTINUATION_NUDGES}): ${nudgeReason} detected in "${lastText.slice(-120)}" without tool calls`, ) const nudge = createUserMessage({ - content: 'Continue with the task. If you were interrupted, resume your thought. Otherwise, use the appropriate tools to proceed to the next step.', + content: + 'Continue with the task. If you were interrupted, resume your thought. Otherwise, use the appropriate tools to proceed to the next step.', isMeta: true, }) const next: State = { diff --git a/src/utils/continuation.ts b/src/utils/continuation.ts new file mode 100644 index 0000000000..bee357bc36 --- /dev/null +++ b/src/utils/continuation.ts @@ -0,0 +1,128 @@ +import { tokenCountWithEstimation } from './tokens.js' + +/** + * Heuristics to detect if the agent intends to continue its task + * but stopped (potentially due to truncation or missed tool calls). + */ + +export const CONTINUATION_SIGNALS = [ + // English: Action-transition phrases (requires intent + action) + /\bso now (i|let me|we) (need to|have to|should|must|will) (do|create|write|edit|update|fix|implement|add|run|check|make|build|set up|start|begin|apply|identify|inspect|analyze|review|search)\b/i, + /\bnow i('ll| will) (do|create|write|edit|update|fix|implement|add|run|check|make|build|set up|go|proceed|start|begin|apply|identify|inspect|analyze|review|search)\b/i, + /\bi (will|shall|now|need to|have to|must|should) (now )?(do|create|write|edit|update|fix|implement|add|run|check|make|build|set up|go|proceed|start|begin|apply|identify|inspect|analyze|review|search)\b/i, + /\blet me (go ahead and |now )?(do|create|write|edit|update|fix|implement|add|run|check|make|build|set up|proceed|start|begin|apply|update|create|identify|inspect|analyze|review|search|summarize)\b/i, + /\btime to (do|create|write|edit|update|fix|implement|add|run|check|make|build|get started|begin|start|inspect|analyze|review|search)\b/i, + /\b(moving on to|next step is to|starting to|proceeding to|applying (the|these) changes|inspecting|analyzing|reviewing|searching)\b/i, + // French: Support for common continuation phrasing (relaxed boundaries for accents and apostrophes) + /(^|\s)(je passe (à|au)|ensuite|l'étape suivante est de|je continue avec|au suivant|passons à|je reviens vers vous|je suis en train d'|je vais maintenant)(\s|$|[a-zà-ÿ])/i, + /(^|\s)(je (vais|dois|dois maintenant|vais maintenant) (faire|créer|écrire|modifier|ajouter|tester|vérifier|lancer|exécuter|procéder|démarrer|commencer|identifier|analyser|inspecter|revoir|chercher))(\s|$|[a-zà-ÿ])/i, + /(^|\s)((lancement|exécution|vérification|modification|mise à jour|analyse|inspection|recherche) de)(\s|$|[a-zà-ÿ])/i, + // Universal: Sentence ending with a colon indicates intent to list/act + /:\s*$/, + // Universal: Open task marker indicates pending work + /◻/, +] + +export const COMPLETION_MARKERS = /\b(done|finished|completed|complete|summary|that's all|that is all|all set|hope this helps|let me know if|no issues|lgtm)\b/i + +export type ContinuationResult = { + shouldNudge: boolean + reason?: 'possible_truncation' | 'continuation_signal' +} + +export const UNFINISHED_SENTIMENT_SIGNALS = [ + // English trailing connectors + /\b(and|with|the|to|of|for|at|by|in|on|a|an|is|are|was|were|my|your|his|her|its|our|their|if|as|but|or|so|which|that)\s*$/i, + // French trailing connectors + /\b(et|avec|le|la|les|un|une|de|du|des|pour|au|aux|dans|sur|par|à|en|si|car|mais|ou|donc|ni|que|ce|ma|ta|sa|mes|tes|ses|notre|votre|leur|nos|vos|leurs)\s*$/i, + // Trailing non-terminal punctuation + /[,;]\s*$/, + // Unclosed code block starter + /```[a-z]*\s*$/i, +] + +/** + * Analyzes assistant text to determine if a continuation nudge is required. + */ +export function analyzeContinuationIntent( + text: string, +): ContinuationResult { + const lastText = text.trim() + if (lastText.length === 0) return { shouldNudge: false } + + const lowerText = lastText.toLowerCase() + + // 1. High-Confidence Structural Truncation signals (Strongest - Ignore completion markers) + + // Check for unclosed markdown code blocks + const codeBlockCount = (lastText.match(/```/g) || []).length + const hasUnclosedCodeBlock = codeBlockCount % 2 !== 0 + + // Check for unclosed structural elements (brackets, parens, braces) + const unclosedPairs = [['(', ')'], ['[', ']'], ['{', '}']] + const hasUnclosedPair = unclosedPairs.some(([open, close]) => { + const openCount = (lastText.match(new RegExp('\\' + open, 'g')) || []).length + const closeCount = (lastText.match(new RegExp('\\' + close, 'g')) || []).length + return openCount > closeCount + }) + + // Check for trailing connectors (e.g., "... and", "... with") + const hasUnfinishedSuffix = UNFINISHED_SENTIMENT_SIGNALS.some(re => re.test(lastText)) + + if (hasUnclosedCodeBlock || hasUnclosedPair || hasUnfinishedSuffix) { + // Structural cut-offs always trigger a nudge, even if "done" was said earlier. + return { shouldNudge: true, reason: 'possible_truncation' } + } + + // 2. Late Intent-based signals (Overriding earlier completion markers) + + // Check if continuation signals match in the last 120 characters + const lateWindowSize = 120 + const lateText = lowerText.slice(-lateWindowSize) + + const hasLateContinuationSignal = CONTINUATION_SIGNALS.some(re => { + const match = lateText.match(re) + if (!match) return false + + // Check if any completion marker follows THIS specific continuation signal in the late window + const afterMatch = lateText.slice(match.index! + match[0].length) + const hasLaterCompletion = COMPLETION_MARKERS.test(afterMatch) + + // Very strong action intents (I will now, Let me, Je vais) override any later markers + const strongAction = /\b(let me|i will|i'll|je vais|je suis en train)\b/i.test(match[0]) + + return strongAction || !hasLaterCompletion + }) + + if (hasLateContinuationSignal) { + // If the sentence is punctuated but has a transition word, only nudge if + // it's a strong 1st person intent or open tasks are present. + const hasTerminalPunctuation = /[.!??"'`)\]]\s*$/.test(lastText) || lastText.endsWith('`') + if (hasTerminalPunctuation) { + const strongIntent = /\b(i (will|shall|need to|must|should|now)|let (me|us)|je (vais|reviens)|passons à|moving on to|next step is to)\b/i.test(lowerText) || + /je suis en train d'/i.test(lowerText) || /◻/.test(lastText) + const endsWithColon = /:\s*$/.test(lastText) + if (strongIntent || endsWithColon) { + return { shouldNudge: true, reason: 'continuation_signal' } + } + } else { + return { shouldNudge: true, reason: 'continuation_signal' } + } + } + + // 3. Completion Marker Guard (Final check for sound, completed messages) + if (COMPLETION_MARKERS.test(lowerText)) { + return { shouldNudge: false } + } + + // Global fallback for unpunctuated signals (must be a clear transition) + const hasTerminalPunctuation = /[.!??"'`)\]]\s*$/.test(lastText) || lastText.endsWith('`') + if ( + CONTINUATION_SIGNALS.some(re => re.test(lowerText)) && + !hasTerminalPunctuation + ) { + return { shouldNudge: true, reason: 'continuation_signal' } + } + + return { shouldNudge: false } +}