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
10 changes: 10 additions & 0 deletions frontend/src/components/ChatArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { ToolPill } from './ToolPill';
import { ToolGroup } from './ToolGroup';
import { PermissionBanner } from './PermissionBanner';
import { ProgressWidget } from './ProgressWidget';
import { SessionBanner } from './SessionBanner';
import { groupBlocks } from '../lib/groupMessages';
import { SCROLL_NEAR_BOTTOM_PX } from '../lib/constants';
import type {
Expand All @@ -13,6 +14,7 @@ import type {
StreamingMessage,
PermissionRequest,
} from '../types/chat';
import type { BootContextMeta } from '@mitzo/client';
import type { ProgressBlock } from '@mitzo/protocol';
import type { UseVoiceReturn } from '../hooks/useVoice';

Expand All @@ -37,6 +39,10 @@ export interface ChatAreaProps {
progressByToolId?: Record<string, ProgressBlock>;
/** Voice capabilities for per-block read-aloud */
voice?: ChatAreaVoice;
/** Boot context metadata for sticky banner */
bootContext?: BootContextMeta | null;
/** Session context string for sticky banner */
sessionContext?: string | null;
}

export function ChatArea({
Expand All @@ -48,6 +54,8 @@ export function ChatArea({
scrollRef: externalScrollRef,
progressByToolId,
voice,
bootContext,
sessionContext,
}: ChatAreaProps) {
const internalScrollRef = useRef<HTMLDivElement>(null);
const scrollRef = externalScrollRef ?? internalScrollRef;
Expand Down Expand Up @@ -143,6 +151,8 @@ export function ChatArea({
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
<SessionBanner bootContext={bootContext} sessionContext={sessionContext} />

{messages.length === 0 && !current && !running && (
<p className="chat-empty">Send a message to start</p>
)}
Expand Down
5 changes: 2 additions & 3 deletions frontend/src/pages/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { useParams, useSearchParams, useNavigate } from 'react-router-dom';
import { ChatArea } from '../components/ChatArea';
import { ChatInput } from '../components/ChatInput';
import { SessionBanner } from '../components/SessionBanner';
import { VoiceSettings } from '../components/VoiceSettings';
import { MitzoLogo } from '../components/MitzoLogo';
import { useMessages, useConnection, useTokens, useMitzoStore } from '@mitzo/client/hooks';
Expand Down Expand Up @@ -272,8 +271,6 @@ export function ChatView() {
)}
</header>

<SessionBanner bootContext={bootContext} sessionContext={sessionContext} />

<ChatArea
messages={messages.messages}
current={messages.current}
Expand All @@ -283,6 +280,8 @@ export function ChatView() {
scrollRef={scrollRef}
progressByToolId={progressByToolId}
voice={voice}
bootContext={bootContext}
sessionContext={sessionContext}
/>

<ChatInput
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/pages/DesktopChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { SessionPanel } from '../components/SessionPanel';
import { CommandCenter } from '../components/CommandCenter';
import { ChatArea } from '../components/ChatArea';
import { ChatInput } from '../components/ChatInput';
import { SessionBanner } from '../components/SessionBanner';
import { ScrollFab } from '../components/ScrollFab';
import { StatusBar } from '../components/StatusBar';
import { VoiceSettings } from '../components/VoiceSettings';
Expand Down Expand Up @@ -242,7 +241,6 @@ export function DesktopChatView() {
onVoiceChange={voice.setVoice}
/>
</header>
<SessionBanner bootContext={bootContext} sessionContext={sessionContext} />
<ChatArea
messages={messages.messages}
current={messages.current}
Expand All @@ -252,6 +250,8 @@ export function DesktopChatView() {
scrollRef={scrollRef}
progressByToolId={progressByToolId}
voice={voice}
bootContext={bootContext}
sessionContext={sessionContext}
/>
<ScrollFab scrollRef={scrollRef} />
<ChatInput
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/styles/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -2410,10 +2410,11 @@ textarea:focus {
/* ===== Session Banner (persistent boot + session context) ===== */

.session-banner {
flex-shrink: 0;
position: sticky;
top: 0;
z-index: 10;
border-bottom: 1px solid var(--border);
background: var(--bg-primary);
z-index: 10;
}

.session-banner-header {
Expand Down
40 changes: 40 additions & 0 deletions server/__tests__/boot-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,46 @@ describe('fetchBootContext', () => {
expect(result.fullMarkdown).toBe('json fallback');
});

it('returns fullMarkdown suitable for system prompt append', async () => {
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 missing_tests: The two new tests reconstruct the append pattern inline and assert against it, but they only test fetchBootContext return values — not that chat.ts actually appends the result to systemPromptAppend. The append logic in chat.ts (lines 906-908, 932) has no direct test coverage. An integration test that verifies the system prompt passed to query() contains the boot context would be more valuable. [fixable]

const markdown = '# Identity\nYou are a helpful assistant.\n\n# Preferences\nBe concise.';
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
boot: {
content: markdown,
tokens: 200,
tokenBudget: 8000,
sources: ['profile.md'],
},
}),
});

const result = await fetchBootContext('mitzo-conversational', CONTEXGIN_URL);

expect(result.fullMarkdown).toBe(markdown);
// Verify the append pattern matches what chat.ts does:
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 style: The test at lines 249-254 is tautological — it constructs a template string and then asserts it contains substrings that are obviously present. It doesn't test any production code path; it tests the test's own string interpolation. The meaningful assertion is on line 248 (result.fullMarkdown === markdown), which the existing test structure already covers. [fixable]

// `\n\n# Boot Context\n${fullMarkdown}`
const append = result.fullMarkdown ? `\n\n# Boot Context\n${result.fullMarkdown}` : '';
expect(append).toContain('# Boot Context');
expect(append).toContain('# Identity');
expect(append).toContain('Be concise.');
});

it('returns empty string for append when fullMarkdown is missing', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
boot: { tokens: 0, sources: [] },
}),
});

const result = await fetchBootContext('mitzo-conversational', CONTEXGIN_URL);

expect(result.fullMarkdown).toBeUndefined();
const append = result.fullMarkdown ? `\n\n# Boot Context\n${result.fullMarkdown}` : '';
expect(append).toBe('');
});

it('uses default URL from env when not provided', async () => {
const origUrl = process.env.CONTEXGIN_URL;
process.env.CONTEXGIN_URL = 'http://test-host:9999';
Expand Down
45 changes: 23 additions & 22 deletions server/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -900,6 +900,26 @@ async function _startChatInner(
// Load project hooks from .claude/settings.json (e.g. SessionStart boot context)
const hooks = loadProjectHooks(cwd);

// Fetch boot context BEFORE building system prompt so it's part of the
// system prompt append and survives SDK context compaction.
const bootContextMsg = await fetchBootContext(agentName);
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 unsafe_assumptions: Changing fetchBootContext from fire-and-forget to await adds up to 10 seconds of latency before the SDK query() call (5s ContexGin timeout + 5s Python fallback timeout in the worst case). The old fire-and-forget approach let the session start immediately while boot context loaded in parallel. This tradeoff is intentional (system prompt needs the content), but worth noting: if ContexGin is slow/down and the Python fallback is also slow, session start stalls noticeably.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 regressions: Changing fetchBootContext from fire-and-forget to awaited adds up to 5 seconds (the AbortSignal.timeout) to session startup latency when ContexGin is slow or unreachable, plus additional time for the local fallback subprocess. The old code let the session start immediately while boot context loaded in the background. Consider whether this latency tradeoff is acceptable — the user will see a blank chat for longer on ContexGin timeouts. [fixable]

const bootContextAppend = bootContextMsg.fullMarkdown
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 missing_tests: The new behavior — boot context markdown being appended to the system prompt — has no test coverage. The existing boot-context.test.ts tests fetchBootContext in isolation, but no test verifies that systemPromptAppend includes the boot context content or that it's correctly gated on fullMarkdown being truthy. An integration-level test (or a unit test for the prompt assembly logic) would catch regressions if the append format changes. [fixable]

? `\n\n# Boot Context\n${bootContextMsg.fullMarkdown}`
: '';

// Send boot context to UI for display (separate from system prompt injection).
// stateSessionId is the worktree-level session ID, already computed above.
send(transport, { ...bootContextMsg, ...(stateSessionId ? { sessionId: stateSessionId } : {}) });
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 regressions: The old fire-and-forget code looked up session.sessionId via registry.get(clientId) at resolution time (after the SDK query started and assigned the real sessionId). The new code uses stateSessionId which is options.resume ?? session.sessionId computed before the SDK query starts. For non-resume sessions, session.sessionId is undefined at this point (it gets set later by the query loop), so the boot_context message sent to the UI and the EventStore persistence will both lack a sessionId. The old code had the same race for the initial send but at least had a chance of resolving it if the fetch was slow enough. Verify this doesn't break client-side session demuxing. [fixable]

// Cache in ManagedSession for replay on reconnect/switch
session.bootContext = bootContextMsg as unknown as Record<string, unknown>;
// Persist to EventStore for cold-path recovery
if (stateSessionId) {
eventStore.upsertSession({
sessionId: stateSessionId,
bootContext: JSON.stringify(bootContextMsg),
});
}

// Build the system prompt append string (used by both query and comparison)
const systemPromptAppend =
'This is Mitzo, a mobile chat interface. The user is on their phone.\n' +
Expand All @@ -908,28 +928,9 @@ async function _startChatInner(
'- Keep responses concise — small screen.\n' +
'- Read CLAUDE.md and .cursor/rules/ for project context before doing substantive work.' +
buildWorktreeSystemPrompt(repoWorktrees) +
buildTaskPromptForSession(clientId);

// Fire-and-forget: fetch boot context from running ContexGin server
fetchBootContext(agentName)
.then((msg) => {
// Include sessionId for consistency with reconnect/switch replay paths
const sid = options.resume ?? registry.get(clientId)?.sessionId;
send(transport, { ...msg, ...(sid ? { sessionId: sid } : {}) });
// Cache in ManagedSession for replay on reconnect/switch
const s = registry.get(clientId);
if (s) s.bootContext = msg as unknown as Record<string, unknown>;
// Persist to EventStore for cold-path recovery (resumed sessions
// may not run the query loop long enough for it to upsert).
if (sid) {
eventStore.upsertSession({ sessionId: sid, bootContext: JSON.stringify(msg) });
}
})
.catch((err: unknown) => {
log.warn('boot context fetch unexpected error', {
error: err instanceof Error ? err.message : String(err),
});
});
buildTaskPromptForSession(clientId) +
bootContextAppend;

capturePromptComparison(wtId, cwd, systemPromptAppend, repoWorktrees).catch(() => {});

// Resolve SDK session UUID for resume — worktree IDs are not valid SDK session IDs
Expand Down
Loading