From e458619f233f55c322c4e1eba646ce883b894f00 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Wed, 23 Sep 2026 11:40:04 +0530 Subject: [PATCH] fix: the first build is named and seeded from the welcome chat's stored reply, not its streamed copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Windows (run 3, 2026-09-23) the renderer's streamed copy of the setup reply repeated its own chunks. The handoff card read its task and brief from that copy, so the build session was titled "Set up mySet up my games an…" and opened with "Set up Sid up Sid's PC's PC for gaming for gaming…", cut at 240 chars. The backend's stored reply held the clean directive. The card now reads the handoff directive from session.history (the persisted reply) and uses those values; an unreachable history falls back to the rendered attrs. It also waits for the whole reply to stop running: `locked` tracks only the text part, which a later part settles mid-reply. --- .../onboarding-chat/cards/build.tsx | 34 +++++++++----- .../onboarding-chat/persisted-handoff.ts | 47 +++++++++++++++++++ 2 files changed, 70 insertions(+), 11 deletions(-) create mode 100644 apps/desktop/src/components/onboarding-chat/persisted-handoff.ts diff --git a/apps/desktop/src/components/onboarding-chat/cards/build.tsx b/apps/desktop/src/components/onboarding-chat/cards/build.tsx index 1457200518..85862205f1 100644 --- a/apps/desktop/src/components/onboarding-chat/cards/build.tsx +++ b/apps/desktop/src/components/onboarding-chat/cards/build.tsx @@ -14,6 +14,7 @@ import { quarantineHandoffReceipt } from '@/app/contrib/handoff-receipt' import { resolveSessionOwner } from '@/app/session/hooks/use-session-actions/utils' import type { CardProps } from '@/components/onboarding-chat/cards/frame' import { Chip } from '@/components/onboarding-chat/chip' +import { readPersistedHandoff } from '@/components/onboarding-chat/persisted-handoff' import { $handoffError, $setupHandoff, @@ -123,6 +124,8 @@ export function HandoffCard({ attrs, locked }: CardProps) { const brief = (attrs.brief ?? '').trim().slice(0, 240) const plan = parseHandoffPlan(attrs.plan) const state = useStore($setupHandoff) + // `locked` follows this text part; a later part (a tool call, reasoning) settles it while the reply still runs. + const replyRunning = useAuiState(s => s.message.status?.type === 'running') const receipt = useMemo(() => { try { @@ -139,24 +142,33 @@ export function HandoffCard({ attrs, locked }: CardProps) { const completed = receipt.completed useEffect(() => { - if (!task || !brief || locked || !storedId || !runtimeId || $setupHandoff.get() || completed) { + if (!task || !brief || locked || replyRunning || !storedId || !runtimeId || $setupHandoff.get() || completed) { return } let cancelled = false void resolveSessionOwner(storedId) - .then(owner => { + .then(async owner => { assertSessionOwnerResolved(owner, { method: 'onboarding.handoff', sessionId: storedId }) + const connectionId = isSessionOwnerRoute(owner) ? owner.connectionId : null + + const profile = isSessionOwnerRoute(owner) + ? owner.profile + : owner || $setupSession.get()?.profile || $activeGatewayProfile.get() + + // An unreachable history keeps the rendered attrs: today's behaviour, never a stalled handoff. + const persisted = await readPersistedHandoff(connectionId, profile, runtimeId).catch(() => null) + const persistedTask = (persisted?.task ?? '').trim().slice(0, 60) + const persistedBrief = (persisted?.brief ?? '').trim().slice(0, 240) + if (!cancelled) { - requestSetupHandoff(task, brief, plan, { - storedId, - runtimeId, - connectionId: isSessionOwnerRoute(owner) ? owner.connectionId : null, - profile: isSessionOwnerRoute(owner) - ? owner.profile - : owner || $setupSession.get()?.profile || $activeGatewayProfile.get() - }) + requestSetupHandoff( + persistedTask || task, + persistedBrief || brief, + persisted ? parseHandoffPlan(persisted.plan) : plan, + { storedId, runtimeId, connectionId, profile } + ) } }) .catch(error => { @@ -169,7 +181,7 @@ export function HandoffCard({ attrs, locked }: CardProps) { return () => { cancelled = true } - }, [brief, locked, plan, task, storedId, runtimeId, completed]) + }, [brief, locked, plan, replyRunning, task, storedId, runtimeId, completed]) if (!task || !brief) { return null diff --git a/apps/desktop/src/components/onboarding-chat/persisted-handoff.ts b/apps/desktop/src/components/onboarding-chat/persisted-handoff.ts new file mode 100644 index 0000000000..98555b50a9 --- /dev/null +++ b/apps/desktop/src/components/onboarding-chat/persisted-handoff.ts @@ -0,0 +1,47 @@ +import type { SessionHistoryResult } from '@hermes/shared' + +import { segmentTranscriptDirectives } from '@/lib/transcript-directives' +import { requestGatewayForAgent } from '@/store/gateway' + +/** + * The handoff directive as the backend persisted it for the welcome chat. The build session is named and seeded + * from these attrs, so they come from the stored reply, not the renderer's streamed copy of it: on Windows the + * streamed copy once repeated its own chunks ("Set up mySet up my games…") while the stored reply was intact, and + * the handoff carried the garbled task and brief into the new session. Null when no persisted reply holds one. + */ +export async function readPersistedHandoff( + connectionId: null | string, + profile: string, + runtimeId: string +): Promise>> { + const history = await requestGatewayForAgent>( + connectionId, + profile, + 'session.history', + { + session_id: runtimeId + } + ) + + const messages = Array.isArray(history?.messages) ? history.messages : [] + + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index] + + if (message?.role !== 'assistant' || typeof message.text !== 'string') { + continue + } + + for (const segment of segmentTranscriptDirectives(message.text) ?? []) { + if ( + segment.kind === 'directive' && + segment.directive.name === 'onboarding' && + segment.directive.attrs.step === 'handoff' + ) { + return segment.directive.attrs + } + } + } + + return null +}