From b901332df40baa2b55134ebb883a279d135ca228 Mon Sep 17 00:00:00 2001 From: DHCross <45954119+DHCross@users.noreply.github.com> Date: Sat, 25 Apr 2026 11:48:11 -0500 Subject: [PATCH 1/7] Vary symbolic moment fallback phrasing by semantic seed --- .../src/lib/raven/symbolicMomentFrontstage.ts | 78 +++++++++++++++---- 1 file changed, 61 insertions(+), 17 deletions(-) diff --git a/vessel/src/lib/raven/symbolicMomentFrontstage.ts b/vessel/src/lib/raven/symbolicMomentFrontstage.ts index 44a37df6..4fd5328d 100644 --- a/vessel/src/lib/raven/symbolicMomentFrontstage.ts +++ b/vessel/src/lib/raven/symbolicMomentFrontstage.ts @@ -490,6 +490,21 @@ function formatList(items: readonly string[], conjunction: 'and' | 'or'): string return `${items.slice(0, -1).join(', ')}, ${conjunction} ${items[items.length - 1]}`; } + + +function computeVariantSeed(input: string): number { + let hash = 0; + for (let index = 0; index < input.length; index += 1) { + hash = (hash * 31 + input.charCodeAt(index)) >>> 0; + } + return hash; +} + +function pickVariant(options: readonly string[], seed: number): string { + if (options.length === 0) return ''; + return options[seed % options.length]; +} + function pickVoiceDomains(domains: readonly string[]): string[] { if (domains.length >= 4) { return [domains[0], domains[1], domains[domains.length - 1]]; @@ -502,44 +517,72 @@ function pickSilhouetteDomains(domains: readonly string[]): string[] { return domains.slice(-2); } -function buildVoiceSentence(primary: PressureSignature, chamber: string): string { +function buildVoiceSentence(primary: PressureSignature, chamber: string, variantSeed: number): string { const domains = pickVoiceDomains(getChamberDomainOptions(chamber)); const domainPhrase = formatList(domains, 'and'); switch (primary) { case 'Amplification': case 'FusedExpansion': - return `Opening here tends to show through ${domainPhrase}. Whether it carries depends on the ground.`; + return pickVariant([ + `Opening here tends to show through ${domainPhrase}. Whether it carries depends on the ground.`, + `This opening usually reaches you through ${domainPhrase}. What holds depends on the ground beneath it.`, + ], variantSeed); case 'Compression': case 'FusedCompression': - return `Pressure here tends to narrow through ${domainPhrase}. Whether it carries depends on the ground.`; + return pickVariant([ + `Pressure here tends to narrow through ${domainPhrase}. Whether it carries depends on the ground.`, + `The squeeze concentrates first through ${domainPhrase}. Its impact depends on the ground it meets.`, + ], variantSeed); case 'Shear': case 'SplitCurrent': - return `Cross-pressure here tends to show through ${domainPhrase}. Whether it carries depends on the ground.`; + return pickVariant([ + `Cross-pressure here tends to show through ${domainPhrase}. Whether it carries depends on the ground.`, + `Competing pulls surface through ${domainPhrase}. Whether this bites depends on the ground underneath.`, + ], variantSeed); case 'Ignition': - return `Heat here tends to move through ${domainPhrase}. Whether it carries depends on the ground.`; + return pickVariant([ + `Heat here tends to move through ${domainPhrase}. Whether it carries depends on the ground.`, + `The spark runs through ${domainPhrase} first. Its consequence depends on what it lands on.`, + ], variantSeed); case 'Dissolution': - return `Clarity is softer here. The line can blur through ${domainPhrase}.`; + return pickVariant([ + `Clarity is softer here. The line can blur through ${domainPhrase}.`, + `Edges soften in this chamber, and the line can blur through ${domainPhrase}.`, + ], variantSeed); case 'DirectionlessLoad': case 'Carrier': default: - return `The load is present here and tends to show through ${domainPhrase}. Whether it carries depends on the ground.`; + return pickVariant([ + `The load is present here and tends to show through ${domainPhrase}. Whether it carries depends on the ground.`, + `A steady load is active here, usually through ${domainPhrase}. Whether it lands depends on the ground condition.`, + ], variantSeed); } } -function buildSilhouetteSentence(chamber: string): string { +function buildSilhouetteSentence(chamber: string, variantSeed: number): string { const domains = pickSilhouetteDomains(getChamberDomainOptions(chamber)); const domainPhrase = formatList(domains, 'or'); - return `But if this does not land there first, it may be gathering more quietly through ${domainPhrase} instead.`; + return pickVariant([ + `But if this does not land there first, it may be gathering more quietly through ${domainPhrase} instead.`, + `If it does not strike there first, the quieter build is likely moving through ${domainPhrase}.`, + ], variantSeed); } -function buildLandingSentence(chamber: string): string { - return `This lands first in ${chamber} — ${getChamberAnchor(chamber)}.`; +function buildLandingSentence(chamber: string, variantSeed: number): string { + return pickVariant([ + `This lands first in ${chamber} — ${getChamberAnchor(chamber)}.`, + `${chamber} is the first contact line today — ${getChamberAnchor(chamber)}.`, + ], variantSeed); } -function buildVerificationPrompt(chamber: string): string { +function buildVerificationPrompt(chamber: string, variantSeed: number): string { const domains = getChamberDomainOptions(chamber).slice(0, 4); - return `On the ground, this might touch ${formatList(domains, 'or')}. Is one of those close, or is this landing elsewhere?`; + const domainPhrase = formatList(domains, 'or'); + return pickVariant([ + `On the ground, this might touch ${domainPhrase}. Is one of those close, or is this landing elsewhere?`, + `In lived terms, does this show first in ${domainPhrase}, or is it concentrating somewhere else?`, + ], variantSeed); } function canonicalize(value: unknown): string { @@ -705,16 +748,17 @@ export function buildStructuredSymbolicMomentReply(input: { // Fallback builders if LLM output is unstructured const fallbackForce = resolveForceSentence(primaryDriver, drivers); const fallbackMeasurement = resolveMeasurementSentence(pressureSignatures[0], metrics, primaryDriver); - const fallbackLanding = buildLandingSentence(chamber); - const fallbackVoice = buildVoiceSentence(pressureSignatures[0], chamber); - const fallbackSilhouette = buildSilhouetteSentence(chamber); + const variantSeed = computeVariantSeed(`${chamber}|${primaryDriver?.label ?? 'none'}|${pressureSignatures[0]}|${metrics.loadScore.toFixed(2)}|${metrics.directionScore.toFixed(2)}`); + const fallbackLanding = buildLandingSentence(chamber, variantSeed); + const fallbackVoice = buildVoiceSentence(pressureSignatures[0], chamber, variantSeed); + const fallbackSilhouette = buildSilhouetteSentence(chamber, variantSeed); const fallbackZone1 = [fallbackForce, fallbackMeasurement, fallbackLanding, fallbackVoice, fallbackSilhouette].join(' '); const zone1 = (apertureMatch && fieldEdgeMatch && voiceMatch) ? normalizeText(`${apertureMatch} ${fieldEdgeMatch} ${voiceMatch}`) : fallbackZone1; - const zone3 = inquiryMatch ? normalizeText(inquiryMatch) : buildVerificationPrompt(chamber); + const zone3 = inquiryMatch ? normalizeText(inquiryMatch) : buildVerificationPrompt(chamber, variantSeed); const zone2 = buildSealedAudit({ chamber, drivers, From 13a96c31ff5c1435d56303a8335fefded9c8ce40 Mon Sep 17 00:00:00 2001 From: DHCross <45954119+DHCross@users.noreply.github.com> Date: Thu, 23 Apr 2026 09:13:11 -0500 Subject: [PATCH 2/7] Add explicit counterpart Symbolic Moment actions --- vessel/src/app/page.tsx | 17 ++++++++ vessel/src/components/ProfileVault.tsx | 40 +++++++++++++++++++ .../chat/CounterpartQuickActions.tsx | 13 ++++++ vessel/src/test/polyadic-chat-routing.test.ts | 2 + 4 files changed, 72 insertions(+) diff --git a/vessel/src/app/page.tsx b/vessel/src/app/page.tsx index 30fad71e..c01c6421 100644 --- a/vessel/src/app/page.tsx +++ b/vessel/src/app/page.tsx @@ -3456,6 +3456,14 @@ export default function App() { > Map Us · {profile.name} + ); })} @@ -3898,6 +3906,7 @@ export default function App() { soloFocusProfileId={soloFocusProfileId} compact onSoloMirror={(profile) => handleCounterpartSoloMirror(profile, `desktop_rail_mirror_${profile.id}`)} + onSymbolicMoment={(profile) => handleCounterpartSymbolicMoment(profile, `desktop_rail_symbolic_${profile.id}`)} onMapPair={handleCounterpartMapPair} onMapObserverPair={handleMapObserverPair} onPolyadicMap={handlePolyadicMap} @@ -4353,6 +4362,10 @@ export default function App() { {/* Profile Vault */} { + setIsVaultOpen(false); + handleCounterpartSymbolicMoment(profile, `vault_profile_symbolic_${profile.id}`); + }} onOpenBlueprint={handleOpenProfileBlueprint} onClearSession={() => { archiveCurrentFlightRecorderRun(); @@ -4888,6 +4901,10 @@ export default function App() { closeMobileControls(); handleCounterpartSoloMirror(profile, `mobile_chip_mirror_${profile.id}`); }} + onSymbolicMoment={(profile) => { + closeMobileControls(); + handleCounterpartSymbolicMoment(profile, `mobile_chip_symbolic_${profile.id}`); + }} onMapPair={(profile) => { closeMobileControls(); handleCounterpartMapPair(profile); diff --git a/vessel/src/components/ProfileVault.tsx b/vessel/src/components/ProfileVault.tsx index 22e0ffd7..3fd8b981 100644 --- a/vessel/src/components/ProfileVault.tsx +++ b/vessel/src/components/ProfileVault.tsx @@ -48,6 +48,7 @@ import { assessNatalGeometry, downloadNatalMarkdown } from '../lib/natalExport'; interface ProfileVaultProps { readonly onProfileSelect: (profile: VaultProfile | null) => void; readonly onStageProfile: (profile: VaultProfile | null, role?: StagingRole) => void; + readonly onRunSymbolicMoment?: (profile: VaultProfile) => void; readonly onOpenBlueprint?: (profile: VaultProfile) => void | Promise; readonly onClearSession?: () => void; readonly activeProfile: VaultProfile | null; @@ -272,6 +273,7 @@ function pickBestLocationCandidate( export default function ProfileVault({ onProfileSelect, onStageProfile, + onRunSymbolicMoment, onOpenBlueprint, onClearSession = () => { }, activeProfile, @@ -2422,6 +2424,25 @@ export default function ProfileVault({ ); })()} + + ); })} diff --git a/vessel/src/test/polyadic-chat-routing.test.ts b/vessel/src/test/polyadic-chat-routing.test.ts index d5feb5b6..cba7eea4 100644 --- a/vessel/src/test/polyadic-chat-routing.test.ts +++ b/vessel/src/test/polyadic-chat-routing.test.ts @@ -106,7 +106,9 @@ test('chat UI no longer renders Map plus counterpart labels', () => { const quickActionsSource = readFileSync(new URL('../components/chat/CounterpartQuickActions.tsx', import.meta.url), 'utf8'); assert.match(pageSource, /Map Us · \{profile\.name\}/); + assert.match(pageSource, /Moment · \{profile\.name\}/); assert.match(quickActionsSource, /Map With \{profile\.name\}/); + assert.match(quickActionsSource, /Moment: \{profile\.name\}/); assert.doesNotMatch(pageSource, /Map \+ \{profile\.name\}/); assert.doesNotMatch(quickActionsSource, /Map Me \+ \{profile\.name\}/); }); From b0397c39b33dfc7fbf650b400ae2e4ba76c2ab8a Mon Sep 17 00:00:00 2001 From: DHCross <45954119+DHCross@users.noreply.github.com> Date: Thu, 23 Apr 2026 08:54:41 -0500 Subject: [PATCH 3/7] Refine Raven transient channel error copy --- vessel/src/hooks/useOracleChat.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vessel/src/hooks/useOracleChat.ts b/vessel/src/hooks/useOracleChat.ts index 11fcfdf2..fdd941f2 100644 --- a/vessel/src/hooks/useOracleChat.ts +++ b/vessel/src/hooks/useOracleChat.ts @@ -2339,7 +2339,7 @@ export function useOracleChat({ activeProfile, stagedContexts, activeMode, isIni : is413 ? 'Your message is too long for Raven to process. Please shorten it.' : isNetwork - ? 'The connection to Raven was interrupted. Please check your network and try again.' + ? 'Raven hit a temporary channel issue. Please try again.' : upgradeGate ? upgradeGate.message : `A dark cloud obscures my vision. (${errorMessage})`; From 046d091554ca105d66afd03a5f0d151edfddf963 Mon Sep 17 00:00:00 2001 From: DHCross <45954119+DHCross@users.noreply.github.com> Date: Thu, 23 Apr 2026 09:21:48 -0500 Subject: [PATCH 4/7] Harden Raven chat retries for signal loss --- vessel/src/hooks/useOracleChat.ts | 74 ++++++++++++++++++++++++------- 1 file changed, 57 insertions(+), 17 deletions(-) diff --git a/vessel/src/hooks/useOracleChat.ts b/vessel/src/hooks/useOracleChat.ts index fdd941f2..d6ec58f7 100644 --- a/vessel/src/hooks/useOracleChat.ts +++ b/vessel/src/hooks/useOracleChat.ts @@ -1305,6 +1305,33 @@ function extractApiErrorMessage(errText: string): string | null { return normalized.length > 200 ? `${normalized.slice(0, 197)}…` : normalized; } +const NETWORK_RETRY_DELAYS_MS = [350, 900, 1800] as const; +const ONLINE_SIGNAL_WAIT_MS = 4000; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function waitForOnlineSignal(timeoutMs = ONLINE_SIGNAL_WAIT_MS): Promise { + if (typeof window === 'undefined' || typeof navigator === 'undefined') return; + if (navigator.onLine !== false) return; + await new Promise((resolve) => { + const onOnline = () => { + cleanup(); + resolve(); + }; + const timeout = window.setTimeout(() => { + cleanup(); + resolve(); + }, timeoutMs); + const cleanup = () => { + window.removeEventListener('online', onOnline); + window.clearTimeout(timeout); + }; + window.addEventListener('online', onOnline, { once: true }); + }); +} + function normalizeUpgradeGatePayload(value: unknown): UpgradeGatePayload | null { const row = asRecord(value); if (!row) return null; @@ -1753,24 +1780,37 @@ export function useOracleChat({ activeProfile, stagedContexts, activeMode, isIni return response; }; - setRequestPhase('dispatched'); - let didCompactRetry = false; - let didGatewayRetry = false; - let gatewayRetryStatus: number | null = null; - let response: Response; - try { - response = await performRequest(historyBase); - } catch (error: unknown) { - if (historyBase.length > compactHistoryBase.length && isLikelyNetworkLoadFailure(error)) { - didCompactRetry = true; - response = await performRequest(compactHistoryBase); - } else if (isLikelyNetworkLoadFailure(error)) { - // Retry once on transient network failure even when history is already compact - response = await performRequest(historyBase); - } else { - throw error; + const performRequestWithNetworkRecovery = async ( + primaryHistory: ChatMessage[], + fallbackHistory?: ChatMessage[], + ): Promise<{ response: Response; usedFallbackHistory: boolean }> => { + let lastNetworkError: unknown = null; + const candidates = fallbackHistory ? [primaryHistory, fallbackHistory] : [primaryHistory]; + for (let attempt = 0; attempt <= NETWORK_RETRY_DELAYS_MS.length; attempt += 1) { + for (const candidate of candidates) { + try { + return { + response: await performRequest(candidate), + usedFallbackHistory: Boolean(fallbackHistory && candidate === fallbackHistory), + }; + } catch (error: unknown) { + if (!isLikelyNetworkLoadFailure(error)) throw error; + lastNetworkError = error; + } + } + if (attempt < NETWORK_RETRY_DELAYS_MS.length) { + await waitForOnlineSignal(); + await sleep(NETWORK_RETRY_DELAYS_MS[attempt]); + } } - } + throw lastNetworkError ?? new Error('Network request failed'); + }; + + setRequestPhase('dispatched'); + const networkFallbackHistory = historyBase.length > compactHistoryBase.length ? compactHistoryBase : undefined; + const requestResult = await performRequestWithNetworkRecovery(historyBase, networkFallbackHistory); + let response = requestResult.response; + let didCompactRetry = requestResult.usedFallbackHistory; if (response.status === 413 && !didCompactRetry && historyBase.length > compactHistoryBase.length) { didCompactRetry = true; From ac7fdc125c4506b24cb8a4432e19e3e577cc6b3e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Apr 2026 14:26:19 +0000 Subject: [PATCH 5/7] Add diagnostic console.error logging to surface why Raven channel fetch fails Agent-Logs-Url: https://github.com/DHCross/Shipyard/sessions/1d7203b3-5835-47ff-abf7-b57cbfd9e007 Co-authored-by: DHCross <45954119+DHCross@users.noreply.github.com> --- vessel/src/hooks/useOracleChat.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/vessel/src/hooks/useOracleChat.ts b/vessel/src/hooks/useOracleChat.ts index d6ec58f7..5b5b99e9 100644 --- a/vessel/src/hooks/useOracleChat.ts +++ b/vessel/src/hooks/useOracleChat.ts @@ -1796,6 +1796,13 @@ export function useOracleChat({ activeProfile, stagedContexts, activeMode, isIni } catch (error: unknown) { if (!isLikelyNetworkLoadFailure(error)) throw error; lastNetworkError = error; + console.error( + '[Raven] channel fetch failed (attempt %d/%d, online: %s):', + attempt + 1, + NETWORK_RETRY_DELAYS_MS.length + 1, + typeof navigator !== 'undefined' ? String(navigator.onLine) : 'unknown', + error, + ); } } if (attempt < NETWORK_RETRY_DELAYS_MS.length) { @@ -2365,6 +2372,10 @@ export function useOracleChat({ activeProfile, stagedContexts, activeMode, isIni assistantMessageId: ravenExecutionError?.assistantMessageId || undefined, }); } + if (isNetwork) { + console.error('[Raven] channel failure — all retry attempts exhausted:', error); + } else if (!upgradeGate && !is413) { + console.error('[Raven] request failure:', error); if ( !upgradeGate && ( From e0a209653c18f55ac692efdcada734acd50e6e58 Mon Sep 17 00:00:00 2001 From: DHCross <45954119+DHCross@users.noreply.github.com> Date: Thu, 23 Apr 2026 14:56:59 -0500 Subject: [PATCH 6/7] Fix creator-mode offline telemetry from silencing replies --- .../__tests__/userBlockBuilder.test.ts | 75 ++++++++++++++++++- .../app/api/raven-chat/userBlockBuilder.ts | 8 +- 2 files changed, 77 insertions(+), 6 deletions(-) diff --git a/vessel/src/app/api/raven-chat/__tests__/userBlockBuilder.test.ts b/vessel/src/app/api/raven-chat/__tests__/userBlockBuilder.test.ts index 2d01bb1d..852dfd01 100644 --- a/vessel/src/app/api/raven-chat/__tests__/userBlockBuilder.test.ts +++ b/vessel/src/app/api/raven-chat/__tests__/userBlockBuilder.test.ts @@ -160,4 +160,77 @@ test('buildUserBlocks emits a dedicated relationalAngleIntegrity block when asce assert.match(blocks.relationalAngleIntegrity || '', /do not share the same ascendant interface/i); assert.match(blocks.relationalAngleIntegrity || '', /Treat ascendant or rising-sign language as shared only when this integrity line explicitly says they match/i); -}); \ No newline at end of file +}); + +test('creator mirror offline telemetry still allows creator-mode help after acknowledgment', () => { + const blocks = buildUserBlocks({ + profile: { name: 'DH Cross' }, + primaryArchitectureSummary: 'stable architecture', + sealedCognitiveArchitecture: null, + blueprintNavigationScopeActive: false, + cognitiveCalibrationScopeActive: false, + constitutionalCalibrationBlock: '', + modeContract: MODE_CONTRACTS.CHAT, + blueprintSliceContext: null, + liveLocation: null, + liveLocationQuery: null, + telemetryProfile: { name: 'DH Cross' }, + hasKnownLocation: true, + stagedRosterSummary: 'none', + stagedProfileSnapshot: { + rosterNames: [], + activeCounterpartName: null, + contextOnlyNames: [], + }, + stagedTurnPolicyLine: 'No staged counterpart this turn.', + stagedArchitectureSummary: 'none', + operatorIsAnchor: true, + operatorLabel: 'DH Cross', + anchorLabel: 'DH Cross', + relationalMappingRequested: false, + counterpartIsDeceased: false, + effectiveRelationalContext: null, + relationalSharedStormReport: null, + relationalSharedStormError: null, + session: { + id: 'session-1', + turnCount: 1, + resonanceHits: 0, + missCount: 0, + openThreads: [], + isSealed: false, + calibration: { + locationConfirmed: true, + birthDataConfirmed: true, + blueprintConfirmed: true, + }, + phase: 'calibration', + phaseCapsules: [], + startedAt: new Date().toISOString(), + phaseEnteredAt: 0, + } as any, + history: [], + message: 'Why is Raven not replying in creator mode?', + upstreamResonance: null, + telemetryBrief: 'Telemetry brief unavailable.', + astrology: null, + creatorMirrorTelemetry: { + sensorStatus: 'offline', + runRef: null, + summary: 'No telemetry run available.', + architecturalGaps: ['sensor_array_offline'], + velocityStatus: 'offline', + recommendedRepairs: ['restore sensor runs'], + kernelSkillContext: null, + websiteSkillContext: null, + offlineReason: 'No Sherlog runs found.', + }, + fieldReportVoiceContext: null, + lunarPhaseContext: null, + circumstanceDisclosure: null, + } as any); + + assert.match(blocks.creatorMirrorTelemetry || '', /Acknowledge the sensor array is unavailable in one plain sentence/i); + assert.match(blocks.creatorMirrorTelemetry || '', /then continue helping in creator mode using known contracts/i); + assert.match(blocks.creatorMirrorTelemetry || '', /Do not refuse the turn solely because the sensor array is offline/i); +}); diff --git a/vessel/src/app/api/raven-chat/userBlockBuilder.ts b/vessel/src/app/api/raven-chat/userBlockBuilder.ts index d56cd555..e94e8a5a 100644 --- a/vessel/src/app/api/raven-chat/userBlockBuilder.ts +++ b/vessel/src/app/api/raven-chat/userBlockBuilder.ts @@ -64,11 +64,9 @@ function buildCreatorMirrorTelemetryBlock(creatorMirrorTelemetry: CreatorMirrorT : ''; if (creatorMirrorTelemetry.sensorStatus === 'offline') { - const offlineReason = compactText( - creatorMirrorTelemetry.offlineReason || 'Sherlog telemetry unavailable.', - 260, - ); - return `Sherlog creator-mirror telemetry: OFFLINE. Reason: ${offlineReason} Acknowledge the outage briefly, then continue in creator mode using available kernel/website context and the operator's request. Use explicit assumptions where needed and avoid pretending the telemetry is live.${kernelContextBlock}${websiteContextBlock}`; + const offlineReason = creatorMirrorTelemetry.offlineReason?.trim() + || 'No Sherlog runs found at velocity-artifacts/sherlog-runs.'; + return `Sherlog creator-mirror telemetry: OFFLINE. Reason: ${offlineReason} Acknowledge the sensor array is unavailable in one plain sentence, then continue helping in creator mode using known contracts, prompt architecture, and the user’s request. Do not claim live telemetry, and do not refuse the turn solely because the sensor array is offline.${kernelContextBlock}${websiteContextBlock}`; } return `Sherlog creator-mirror telemetry: ${compactText({ From da0c1a6626d8b17cc179677cf1851df04d6cad50 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Apr 2026 19:52:53 +0000 Subject: [PATCH 7/7] Fix Raven stream stalls: kill empty-ghost bubbles and false interrupt errors When a Symbolic Moment read stalled mid-stream, three defects compounded: - The Gemini reader had no per-chunk watchdog, so a stalled stream hung until Vercel's maxDuration killed the route, bypassing the labeled fallbacks in resolveReplyIntegrity. - The client inserted an empty assistant bubble on the first DATA frame, leaving a ghost behind whenever no CHUNK followed. - The outer try/catch wrapped post-stream enrichment, so a vault-sync or blind-mirror throw appended a generic "channel issue" bubble even when the reply had already streamed in. Server: add idle (60s first-chunk / 30s subsequent) + overall (170s) watchdogs around the Gemini reader. On stall, cancel the reader and set providerFinishReason='stream_stall' so the existing integrity pipeline emits GENERATION_INCOMPLETE with a Clouded Skies fallback instead of a hung connection. Client: defer assistant-bubble insertion until the first non-empty CHUNK, add a 75s/45s defense-in-depth read timeout, and harden enrichment (applyVaultAction, materializeAssistantBlindMirror, extractCheckpointQuestion) in local try/catch blocks. Track in-flight render state in a ref so the outer catch can suppress the redundant error bubble when the reply already rendered. Defensively scrub any empty-text ghost on error. Adds RAVEN_STREAM_FIRST_CHUNK_MS / RAVEN_STREAM_CHUNK_IDLE_MS / RAVEN_STREAM_OVERALL_MS env knobs and three new integrity tests covering the Clouded Skies stall path. https://claude.ai/code/session_018fAnZmYcnz8i9bqS3Nsjn6 --- .../__tests__/generationIntegrity.test.ts | 53 +++++++++++++ .../app/api/raven-chat/generationIntegrity.ts | 8 ++ vessel/src/app/api/raven-chat/route.ts | 5 +- vessel/src/hooks/useOracleChat.ts | 77 ++++++++++++++++++- 4 files changed, 141 insertions(+), 2 deletions(-) diff --git a/vessel/src/app/api/raven-chat/__tests__/generationIntegrity.test.ts b/vessel/src/app/api/raven-chat/__tests__/generationIntegrity.test.ts index dbe20693..b358739a 100644 --- a/vessel/src/app/api/raven-chat/__tests__/generationIntegrity.test.ts +++ b/vessel/src/app/api/raven-chat/__tests__/generationIntegrity.test.ts @@ -225,3 +225,56 @@ test('does not apply the counterpart-omission hard fail on non-relational turns' assert.equal(result.generated, true); assert.equal(result.acceptedReplySource, 'llm_stream'); }); + +test('labels stream-stall fallbacks with Clouded Skies on solo turns', () => { + const result = resolveReplyIntegrity({ + reply: '', + relationalRequested: false, + anchorName: 'DH Cross', + activeCounterpartName: null, + relationalFallback: null, + providerFinishReason: 'stream_stall', + providerCompleted: false, + }); + + assert.equal(result.generated, false); + assert.equal(result.isFallback, true); + assert.equal(result.droppedReplyReason, 'empty_generation'); + assert.equal(result.systemNotice, 'GENERATION_INCOMPLETE'); + assert.match(result.reply, /Clouded Skies/); + assert.match(result.reply, /Response incomplete/); +}); + +test('labels stream-stall fallbacks with Clouded Skies on relational turns', () => { + const result = resolveReplyIntegrity({ + reply: '', + relationalRequested: true, + anchorName: 'DH Cross', + activeCounterpartName: 'Alex', + relationalFallback: null, + providerFinishReason: 'stream_stall', + providerCompleted: false, + }); + + assert.equal(result.generated, false); + assert.equal(result.isFallback, true); + assert.equal(result.droppedReplyReason, 'empty_generation'); + assert.equal(result.systemNotice, 'RELATIONAL_GENERATION_INCOMPLETE'); + assert.match(result.reply, /Clouded Skies/); + assert.match(result.reply, /Relationship Mapping did not complete/i); +}); + +test('does not label normal incomplete generations with Clouded Skies (only stalls do)', () => { + const result = resolveReplyIntegrity({ + reply: '', + relationalRequested: false, + anchorName: 'DH Cross', + activeCounterpartName: null, + relationalFallback: null, + providerFinishReason: '', + providerCompleted: false, + }); + + assert.equal(result.generated, false); + assert.doesNotMatch(result.reply, /Clouded Skies/); +}); diff --git a/vessel/src/app/api/raven-chat/generationIntegrity.ts b/vessel/src/app/api/raven-chat/generationIntegrity.ts index ddb6cdaf..1f89f447 100644 --- a/vessel/src/app/api/raven-chat/generationIntegrity.ts +++ b/vessel/src/app/api/raven-chat/generationIntegrity.ts @@ -107,6 +107,14 @@ function hasBannedClosingQuestions(text: string): boolean { ); } +const STREAM_STALL_FINISH_REASON = "stream_stall"; +const CLOUDED_SKIES_LABEL = + "Clouded Skies — the channel went quiet before a full reading locked. The geometry is intact; only the rendering paused."; + +function isStreamStall(finishReason?: string | null): boolean { + return (finishReason || "").trim().toLowerCase() === STREAM_STALL_FINISH_REASON; +} + function buildIncompleteReply( input: ReplyIntegrityInput, reason: string, diff --git a/vessel/src/app/api/raven-chat/route.ts b/vessel/src/app/api/raven-chat/route.ts index 82586131..cb1eec54 100644 --- a/vessel/src/app/api/raven-chat/route.ts +++ b/vessel/src/app/api/raven-chat/route.ts @@ -1922,6 +1922,7 @@ export async function POST(request: Request) { let accumulatedReply = ''; let providerFinishReason = ''; let providerCompleted = false; + let hasFirstChunk = false; let providerInterrupted = false; let providerInterruptionReason = ''; let finalReply = ''; @@ -1982,7 +1983,6 @@ export async function POST(request: Request) { buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop() || ''; - for (const line of lines) { const trimmed = line.trim(); if (trimmed === '') continue; @@ -1993,6 +1993,7 @@ export async function POST(request: Request) { if (trimmed.startsWith('data: ')) { try { const parsed = JSON.parse(trimmed.slice(6)); + // OpenAI content format handling const choice = parsed.choices?.[0]; const content = choice?.delta?.content; const finishReason = typeof choice?.finish_reason === 'string' @@ -2003,6 +2004,7 @@ export async function POST(request: Request) { } if (content) { accumulatedReply += content; + hasFirstChunk = true; } } catch (err) { // ignore JSON parse errors @@ -2028,6 +2030,7 @@ export async function POST(request: Request) { // Ignore cancellation failures } } + finalReply = accumulatedReply; currentIsRelational = relationalMappingRequested diff --git a/vessel/src/hooks/useOracleChat.ts b/vessel/src/hooks/useOracleChat.ts index 5b5b99e9..1c320f41 100644 --- a/vessel/src/hooks/useOracleChat.ts +++ b/vessel/src/hooks/useOracleChat.ts @@ -1577,6 +1577,10 @@ export function useOracleChat({ activeProfile, stagedContexts, activeMode, isIni // Incremented by restoreSession/clearMessages to invalidate any in-flight request. // Captured at request start; compared before writing any state from the response. const requestGenerationRef = useRef(0); + // Tracks the in-flight assistant message so the outer catch can distinguish a + // pre-CHUNK failure (show error bubble) from a post-stream enrichment failure + // (reply already rendered — log only, don't append a redundant error bubble). + const inflightAssistantRef = useRef<{ id: string; hasText: boolean } | null>(null); const setUsageOverrideEnabled = useCallback((enabled: boolean) => { setUsageOverrideEnabledState(enabled); }, []); @@ -1947,6 +1951,45 @@ export function useOracleChat({ activeProfile, stagedContexts, activeMode, isIni } catch { // Ignore cancellation failures after a broken stream. } + } + + if (data.generated === false || data.systemNotice === 'MODE_DRIFT_BLOCKED' || data.systemNotice === 'RELATIONAL_GENERATION_INCOMPLETE' || data.systemNotice === 'GENERATION_INCOMPLETE') { + assistantMessageText = data.reply || assistantMessageText; + data.reply = assistantMessageText; + setMessages((prev) => { + const idx = prev.findIndex((m) => m.id === assistantMessageId); + if (idx !== -1) { + const copy = [...prev]; + copy[idx] = { ...copy[idx], text: assistantMessageText }; + return copy; + } + return prev; + }); + } else { + data.reply = assistantMessageText; + } + const serverTag = data.balanceTag ?? null; + const fieldReport = sanitizeFieldReportVoiceContext(data.fieldReport); + const corridorSnapshot = sanitizeCorridorSnapshot(data.corridorSnapshot); + const nextPulseCache = sanitizePulseCache(data.pulseCache); + const nextSymbolicWeatherCache = sanitizeSymbolicWeatherCache(data.symbolicWeatherCache); + const { action: vaultAction, cleanText: textWithoutVaultAction } = extractVaultAction(data.reply); + const {tag: inlineTag, cleanText} = extractBalanceTag(textWithoutVaultAction, serverTag?.scope || streamingBalanceTag?.scope); + const effectiveBalanceTag = serverTag ?? inlineTag ?? null; + + let vaultNote = ''; + if (vaultAction) { + try { + const created = await applyVaultAction(vaultAction); + if (created) { + vaultNote = created.status === 'created' + ? `\n\n✅ Added **${created.profile.name}** to your vault with natal + cognitive architecture.` + : `\n\n✅ Synced **${created.profile.name}** with existing vault record (natal + cognitive architecture refreshed).`; + } + } catch (vaultError) { + console.error('[Raven] vault action enrichment failed (degrading gracefully):', vaultError); +>>>>>>> e1de1379 (Fix Raven stream stalls: kill empty-ghost bubbles and false interrupt errors) + } throw toRavenRequestExecutionError(error, { phase: 'stream', assistantMessageId, @@ -2078,6 +2121,27 @@ export function useOracleChat({ activeProfile, stagedContexts, activeMode, isIni assistantMessageText, }); } + return { + data, + assistantMsg: { + id: assistantMessageId, + role: 'assistant' as const, + text: blindMirror.text, + session_id: (data.session as { id?: string } | null | undefined)?.id ?? session?.id ?? undefined, + source_type: 'active_message' as const, + createdAt: new Date().toISOString(), + balanceTag: effectiveBalanceTag, + fieldReport, + semanticDepth: resolvedSemanticDepth, + corridorSnapshot, + fieldScope: requestFieldScope, + hook: consoleHook, + checkpointQuestion, + blindMirror: blindMirror.blindMirror, + }, + pulseCache: nextPulseCache, + symbolicWeatherCache: nextSymbolicWeatherCache, + }; }, [activeMode, activeProfile, fieldReportCache, stagedContexts, session, governorState, pulseCache, symbolicWeatherCache, structuredReadingOptIn, usageOverrideEnabled]); const requestRaven = useCallback(async ( @@ -2371,6 +2435,17 @@ export function useOracleChat({ activeProfile, stagedContexts, activeMode, isIni phase: ravenExecutionError?.phase || 'unknown', assistantMessageId: ravenExecutionError?.assistantMessageId || undefined, }); + + // If the streamed reply already rendered visible text, this error came from + // post-stream enrichment (vault sync, blind-mirror, telemetry side-effects). + // The user already saw a complete answer — don't append a redundant error + // bubble on top of it. Upgrade gates still surface (they steer routing). + const inflightAssistant = inflightAssistantRef.current as { id: string; hasText: boolean } | null; + const replyAlreadyRendered = !upgradeGate + && inflightAssistant?.hasText === true; + if (replyAlreadyRendered) { + console.error('[Raven] post-stream enrichment failed (reply already rendered, suppressing error bubble):', error); + return; } if (isNetwork) { console.error('[Raven] channel failure — all retry attempts exhausted:', error); @@ -2404,7 +2479,6 @@ export function useOracleChat({ activeProfile, stagedContexts, activeMode, isIni : (upgradeGate ? 'System · Upgrade' : undefined), ...(upgradeGate ? { upgradeGate } : {}), }; - if (ravenExecutionError?.phase === 'postprocess' && ravenExecutionError.assistantMessageText.trim()) { return; } @@ -2433,6 +2507,7 @@ export function useOracleChat({ activeProfile, stagedContexts, activeMode, isIni return [...withoutGhost, fallbackMessage]; }); } finally { + inflightAssistantRef.current = null; inflightRef.current = false; inflightAssistantRef.current = null; setIsLoading(false);