diff --git a/.changeset/web-goal-card-refresh.md b/.changeset/web-goal-card-refresh.md new file mode 100644 index 0000000000..78f905b840 --- /dev/null +++ b/.changeset/web-goal-card-refresh.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Fix the goal card disappearing after a page refresh while a session goal is active. diff --git a/apps/kimi-web/src/api/daemon/client.ts b/apps/kimi-web/src/api/daemon/client.ts index 67cf1c4804..5b07d2cd26 100644 --- a/apps/kimi-web/src/api/daemon/client.ts +++ b/apps/kimi-web/src/api/daemon/client.ts @@ -5,6 +5,7 @@ import type { KimiApiConfig } from '../config'; import { buildRestUrl, buildWsUrl } from '../config'; import type { AppConfig, + AppGoal, AppMessage, AppMessageRole, AppModel, @@ -40,6 +41,7 @@ import { toAppConfig, toAppEvent, toAppFsEntry, + toAppGoal, toAppMessage, toAppModel, toAppProvider, @@ -63,6 +65,7 @@ import type { WireFsBrowseResult, WireFsEntry, WireFsHomeResult, + WireGoalSnapshot, WireMessage, WireModel, WireOAuthCancelResult, @@ -413,6 +416,17 @@ export class DaemonKimiWebApi implements KimiWebApi { }; } + /** + * GET /sessions/{id}/goal — the session's current goal, or null when no goal + * is active. + */ + async getSessionGoal(sessionId: string): Promise { + const data = await this.http.get( + `/sessions/${encodeURIComponent(sessionId)}/goal`, + ); + return toAppGoal(data); + } + async getSessionWarnings(sessionId: string): Promise { const data = await this.http.get( `/sessions/${encodeURIComponent(sessionId)}/warnings`, diff --git a/apps/kimi-web/src/api/daemon/eventReducer.ts b/apps/kimi-web/src/api/daemon/eventReducer.ts index 970fbc925a..8a04f7fe51 100644 --- a/apps/kimi-web/src/api/daemon/eventReducer.ts +++ b/apps/kimi-web/src/api/daemon/eventReducer.ts @@ -55,6 +55,10 @@ export interface KimiClientState { questionsBySession: Record; tasksBySession: Record; goalBySession: Record; + /** Monotonic per-session counter bumped on EVERY `goalUpdated` event — + * including delete/clear ones — so an async recovery read can detect that a + * live event won the race even when the goal entry stayed absent. */ + goalVersionBySession: Record; lastSeqBySession: Record; compactionBySession: Record; config?: AppConfig | null; @@ -71,6 +75,7 @@ export function createInitialState(): KimiClientState { questionsBySession: {}, tasksBySession: {}, goalBySession: {}, + goalVersionBySession: {}, lastSeqBySession: {}, compactionBySession: {}, warnings: [], @@ -97,6 +102,7 @@ function cloneState(s: KimiClientState): KimiClientState { questionsBySession: { ...s.questionsBySession }, tasksBySession: { ...s.tasksBySession }, goalBySession: { ...s.goalBySession }, + goalVersionBySession: { ...s.goalVersionBySession }, lastSeqBySession: { ...s.lastSeqBySession }, compactionBySession: { ...s.compactionBySession }, warnings: [...s.warnings], @@ -613,6 +619,9 @@ export function reduceAppEvent( // ------------------------------------------------------------------------- case 'goalUpdated': { const sid = event.sessionId; + // Bump on every goal event — including clears — so refreshSessionGoal's + // recovery read can detect any live event that landed mid-flight. + next.goalVersionBySession[sid] = (next.goalVersionBySession[sid] ?? 0) + 1; if (event.goal === null || event.goal.status === 'complete') { delete next.goalBySession[sid]; } else { diff --git a/apps/kimi-web/src/api/daemon/mappers.ts b/apps/kimi-web/src/api/daemon/mappers.ts index 85b81421a1..8c72aa5296 100644 --- a/apps/kimi-web/src/api/daemon/mappers.ts +++ b/apps/kimi-web/src/api/daemon/mappers.ts @@ -429,7 +429,7 @@ function recordNullableNumber(source: Record, key: string): num return typeof value === 'number' && Number.isFinite(value) ? value : null; } -function toAppGoal(snapshot: unknown): AppGoal | null { +export function toAppGoal(snapshot: unknown): AppGoal | null { if (!snapshot || typeof snapshot !== 'object') return null; const source = snapshot as Record; const status = recordString(source, 'status'); diff --git a/apps/kimi-web/src/api/daemon/wire.ts b/apps/kimi-web/src/api/daemon/wire.ts index e03f37a94a..4d6fd768fc 100644 --- a/apps/kimi-web/src/api/daemon/wire.ts +++ b/apps/kimi-web/src/api/daemon/wire.ts @@ -110,6 +110,31 @@ export interface WireSessionRuntimeStatus { context_usage: number; } +// GET /sessions/{id}/goal — camelCase, same shape as the `goal.updated` event +// payload. The endpoint returns null when no goal is active. +export interface WireGoalSnapshot { + goalId: string; + objective: string; + completionCriterion?: string; + status: 'active' | 'paused' | 'blocked' | 'complete'; + turnsUsed: number; + tokensUsed: number; + wallClockMs: number; + terminalReason?: string; + budget: { + tokenBudget: number | null; + turnBudget: number | null; + wallClockBudgetMs: number | null; + remainingTokens: number | null; + remainingTurns: number | null; + remainingWallClockMs: number | null; + tokenBudgetReached: boolean; + turnBudgetReached: boolean; + wallClockBudgetReached: boolean; + overBudget: boolean; + }; +} + // GET /sessions/{id}/warnings — session-level warnings (e.g. oversized AGENTS.md). export interface WireSessionWarning { code: string; diff --git a/apps/kimi-web/src/api/types.ts b/apps/kimi-web/src/api/types.ts index 8f0309c956..a9d038a337 100644 --- a/apps/kimi-web/src/api/types.ts +++ b/apps/kimi-web/src/api/types.ts @@ -664,6 +664,8 @@ export interface KimiWebApi { getSession(sessionId: string): Promise; updateSession(sessionId: string, input: { title?: string; cwd?: string; model?: string; permissionMode?: string; planMode?: boolean; swarmMode?: boolean; goalObjective?: string; goalControl?: 'pause' | 'resume' | 'cancel'; thinking?: string }): Promise; getSessionStatus(sessionId: string): Promise; + /** Current goal snapshot, or null when the session has no active goal. */ + getSessionGoal(sessionId: string): Promise; getSessionWarnings(sessionId: string): Promise; archiveSession(sessionId: string): Promise<{ archived: true }>; restoreSession(sessionId: string): Promise; diff --git a/apps/kimi-web/src/composables/client/useWorkspaceState.ts b/apps/kimi-web/src/composables/client/useWorkspaceState.ts index 078192aa16..cfb75fb596 100644 --- a/apps/kimi-web/src/composables/client/useWorkspaceState.ts +++ b/apps/kimi-web/src/composables/client/useWorkspaceState.ts @@ -220,6 +220,7 @@ export interface UseWorkspaceStateDeps { reopenSession: (sessionId: string) => Promise; hasLoadedMessages: (sessionId: string) => boolean; refreshSessionStatus: (sessionId: string) => Promise; + refreshSessionGoal: (sessionId: string) => Promise; persistSessionProfile: (patch: PersistSessionProfilePatch, sessionId?: string) => Promise; mergedWorkspaces: ComputedRef; /** Sidebar-facing workspaces in the user's (dragged) display order. */ @@ -271,6 +272,7 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta reopenSession, hasLoadedMessages, refreshSessionStatus, + refreshSessionGoal, persistSessionProfile, mergedWorkspaces, workspacesView, @@ -339,6 +341,7 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta void taskPoller.loadTasksForSession(sessionId); void loadGitStatus(sessionId); void refreshSessionStatus(sessionId); + void refreshSessionGoal(sessionId); if (!Object.prototype.hasOwnProperty.call(modelProvider.skillsBySession.value, sessionId)) { void modelProvider.loadSkillsForSession(sessionId); } diff --git a/apps/kimi-web/src/composables/useKimiWebClient.ts b/apps/kimi-web/src/composables/useKimiWebClient.ts index 03fb9d4f68..3197d29b0b 100644 --- a/apps/kimi-web/src/composables/useKimiWebClient.ts +++ b/apps/kimi-web/src/composables/useKimiWebClient.ts @@ -649,6 +649,38 @@ async function refreshSessionStatus(sessionId: string): Promise { rawState.planModeBySession = { ...rawState.planModeBySession, [sessionId]: st.planMode }; } +/** + * Fetch GET /sessions/{id}/goal and fold the result into goalBySession — the + * recovery channel for the goal card after a full-page reload (the snapshot + + * WS-replay path never carries the historical `goal.updated`, since its seq is + * ≤ the snapshot watermark). Never throws — an old daemon without the /goal + * endpoint keeps any live-event state. + */ +async function refreshSessionGoal(sessionId: string): Promise { + // A live `goal.updated` arriving during the request is newer than whatever + // the server read when handling it — never let this recovery write override + // such an event (it would resurrect a finished goal until the next reload). + // Track the per-session goal event version, not the goal entry itself: + // clear/complete events DELETE the entry, which would leave an + // undefined === undefined comparison blind to exactly the race that matters. + const versionBefore = rawState.goalVersionBySession[sessionId] ?? 0; + let goal: AppGoal | null; + try { + goal = await getKimiWebApi().getSessionGoal(sessionId); + } catch { + return; // goal endpoint missing/unreachable — keep what we have. + } + if ((rawState.goalVersionBySession[sessionId] ?? 0) !== versionBefore) { + return; // a live goal event won the race + } + // Mirror the reducer's goalUpdated branch: null (or a completed goal) clears + // the card, anything else replaces it. + const nextGoals = { ...rawState.goalBySession }; + if (goal === null || goal.status === 'complete') delete nextGoals[sessionId]; + else nextGoals[sessionId] = goal; + rawState.goalBySession = nextGoals; +} + /** Persist runtime controls to a session via POST /profile, then re-read * /status. `sessionId` overrides the active session — used when creating a * session and immediately persisting its draft modes, so a concurrent session @@ -758,6 +790,7 @@ function applyEvent(event: ReturnType, sessionId: string, seq questionsBySession: rawState.questionsBySession, tasksBySession: rawState.tasksBySession, goalBySession: rawState.goalBySession, + goalVersionBySession: rawState.goalVersionBySession, lastSeqBySession: rawState.lastSeqBySession, compactionBySession: rawState.compactionBySession, config: rawState.config, @@ -773,6 +806,7 @@ function applyEvent(event: ReturnType, sessionId: string, seq rawState.questionsBySession = next.questionsBySession; rawState.tasksBySession = next.tasksBySession; rawState.goalBySession = next.goalBySession; + rawState.goalVersionBySession = next.goalVersionBySession; rawState.lastSeqBySession = next.lastSeqBySession; rawState.compactionBySession = next.compactionBySession; rawState.config = next.config ?? null; @@ -2394,6 +2428,7 @@ const workspaceState = useWorkspaceState(rawState, { reopenSession, hasLoadedMessages, refreshSessionStatus, + refreshSessionGoal, persistSessionProfile, mergedWorkspaces, workspacesView, diff --git a/apps/kimi-web/test/daemon-client.test.ts b/apps/kimi-web/test/daemon-client.test.ts new file mode 100644 index 0000000000..d24461289d --- /dev/null +++ b/apps/kimi-web/test/daemon-client.test.ts @@ -0,0 +1,77 @@ +// apps/kimi-web/test/daemon-client.test.ts +// DaemonKimiWebApi.getSessionGoal — wire → app mapping of GET /sessions/{id}/goal: +// a present snapshot, explicit null (no active goal), and the request URL. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { DaemonKimiWebApi } from '../src/api/daemon/client'; + +function envelope(data: unknown): Response { + return new Response(JSON.stringify({ code: 0, msg: '', data }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); +} + +const WIRE_GOAL = { + goalId: 'goal_1', + objective: 'fix all lint warnings', + status: 'active', + turnsUsed: 1, + tokensUsed: 0, + wallClockMs: 0, + budget: { + tokenBudget: null, + turnBudget: null, + wallClockBudgetMs: null, + remainingTokens: null, + remainingTurns: null, + remainingWallClockMs: null, + tokenBudgetReached: false, + turnBudgetReached: false, + wallClockBudgetReached: false, + overBudget: false, + }, +}; + +function createApi(): DaemonKimiWebApi { + return new DaemonKimiWebApi({ + serverHttpUrl: 'http://daemon.test', + clientId: 'web_test', + clientName: 'test', + clientVersion: '0.0.0', + clientUiMode: 'test', + }); +} + +describe('DaemonKimiWebApi.getSessionGoal', () => { + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('maps a present goal snapshot', async () => { + vi.mocked(fetch).mockResolvedValue(envelope(WIRE_GOAL)); + const goal = await createApi().getSessionGoal('sess_1'); + expect(goal?.objective).toBe('fix all lint warnings'); + expect(goal?.status).toBe('active'); + expect(goal?.turnsUsed).toBe(1); + }); + + it('maps null to null (no active goal)', async () => { + vi.mocked(fetch).mockResolvedValue(envelope(null)); + const goal = await createApi().getSessionGoal('sess_1'); + expect(goal).toBeNull(); + }); + + it('requests the session goal endpoint', async () => { + vi.mocked(fetch).mockResolvedValue(envelope(null)); + await createApi().getSessionGoal('sess_42'); + expect(vi.mocked(fetch).mock.calls[0]?.[0]).toBe( + 'http://daemon.test/api/v1/sessions/sess_42/goal', + ); + }); +}); diff --git a/apps/kimi-web/test/workspace-state.test.ts b/apps/kimi-web/test/workspace-state.test.ts index 53f41bbf2a..dfecb77db9 100644 --- a/apps/kimi-web/test/workspace-state.test.ts +++ b/apps/kimi-web/test/workspace-state.test.ts @@ -123,6 +123,7 @@ function createDeps(): UseWorkspaceStateDeps { subscribeToSessionEvents: vi.fn(), hasLoadedMessages: vi.fn(), refreshSessionStatus: vi.fn(), + refreshSessionGoal: vi.fn(), persistSessionProfile: vi.fn(), mergedWorkspaces: computed(() => []), workspacesView: computed(() => []), diff --git a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacy.ts b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacy.ts index 6538e9fc67..955b4a1f49 100644 --- a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacy.ts +++ b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacy.ts @@ -2,8 +2,9 @@ * `sessionLegacy` domain (L7 edge adapter) — v1-compatible session actions. * * Implements `POST /sessions/{id}/profile` (`updateProfile` — title rename, - * metadata merge, and the cross-domain `agent_config` patch) and - * `GET /sessions/{id}/status` (`status`) on top of the native v2 services + * metadata merge, and the cross-domain `agent_config` patch), + * `GET /sessions/{id}/status` (`status`), and `GET /sessions/{id}/goal` + * (`goal`) on top of the native v2 services * (`ISessionLifecycleService`, `IAgentProfileService`, …). * * The thin pass-through actions (`fork` / `compact` / `abort` / `archive`), the @@ -13,15 +14,19 @@ * `IAgentFullCompactionService.begin`, `IAgentRPCService.cancel`, * `IAgentPromptService.undo`, and `ISessionIndex.list({ childOf })` — because * none of them carries v1-only projection worth centralizing beyond what the - * native services already provide. Only `updateProfile` and `status` hold real - * cross-domain adaptation logic (the `agent_config` patch and the - * best-effort status rollup), so they stay in this adapter. The native services + * native services already provide. Only `updateProfile`, `status`, and `goal` + * stay in this adapter (the `agent_config` patch, the best-effort status + * rollup, and the current-goal read). The native services * keep serving `/api/v2` and are left untouched; this adapter exists only so * clients of the v1 server keep working against server-v2. Bound at App scope — * it is a stateless dispatcher that resolves the target session/agent per call. */ -import type { SessionStatusResponse, UpdateSessionProfileRequest } from '@moonshot-ai/protocol'; +import type { + GoalSnapshot, + SessionStatusResponse, + UpdateSessionProfileRequest, +} from '@moonshot-ai/protocol'; import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; @@ -47,6 +52,8 @@ export interface ISessionLegacyService { updateProfile(sessionId: string, body: UpdateSessionProfileRequest): Promise; status(sessionId: string): Promise; + /** Current goal snapshot, or null when the session has no active goal. */ + goal(sessionId: string): Promise; } export const ISessionLegacyService: ServiceIdentifier = diff --git a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts index 9353c58f23..81d59594a3 100644 --- a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts +++ b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts @@ -4,7 +4,8 @@ * Stateless App-scope dispatcher: each method resolves the target session (and * its main agent) per call, delegates to the native v2 services, and projects * the result into the v1 wire shape. Only `updateProfile` (the cross-domain - * `agent_config` patch) and `status` (the best-effort status rollup) live here; + * `agent_config` patch), `status` (the best-effort status rollup), and `goal` + * (the current-goal read) live here; * the `:undo`, `fork`-as-child, and child-listing actions were pushed down into * the native services (`IAgentPromptService.undo`, * `ISessionLifecycleService.createChild`, `ISessionIndex.list({ childOf })`) and @@ -12,7 +13,11 @@ * the real work stays in the native services. */ -import type { SessionStatusResponse, UpdateSessionProfileRequest } from '@moonshot-ai/protocol'; +import type { + GoalSnapshot, + SessionStatusResponse, + UpdateSessionProfileRequest, +} from '@moonshot-ai/protocol'; import { InstantiationType } from '#/_base/di/extensions'; import { type IAgentScopeHandle, LifecycleScope, registerScopedService } from '#/_base/di/scope'; @@ -209,6 +214,11 @@ export class SessionLegacyService implements ISessionLegacyService { context_usage: maxTokens > 0 ? tokens / maxTokens : 0, }; } + + async goal(sessionId: string): Promise { + const agent = await this.resolveMainAgent(sessionId); + return agent.accessor.get(IAgentGoalService).getGoal().goal; + } } /** diff --git a/packages/kap-server/src/routes/sessions.ts b/packages/kap-server/src/routes/sessions.ts index e67b9e3533..51326c7db1 100644 --- a/packages/kap-server/src/routes/sessions.ts +++ b/packages/kap-server/src/routes/sessions.ts @@ -13,6 +13,7 @@ * GET /sessions/{session_id}/children list child sessions * POST /sessions/{session_id}/children create child session (fork+tag) * GET /sessions/{session_id}/status best-effort + * GET /sessions/{session_id}/goal current goal (null when none) * GET /sessions/{session_id}/warnings agents-md-oversized notice * * The `POST /sessions/{tail}` actions split into two groups. The thin @@ -26,9 +27,10 @@ * `/sessions/{id}/children` endpoints call `ISessionLifecycleService.createChild` * and `ISessionIndex.list({ childOf })` directly — the child markers and * parent-title default live in the lifecycle, and the child filter lives in the - * index. Only `POST /sessions/{id}/profile` (`updateProfile`) and - * `GET /sessions/{id}/status` go through `ISessionLegacyService` (the - * `agent_config` patch and the status rollup hold real cross-domain adaptation); + * index. Only `POST /sessions/{id}/profile` (`updateProfile`), + * `GET /sessions/{id}/status`, and `GET /sessions/{id}/goal` go through + * `ISessionLegacyService` (the `agent_config` patch, the status rollup, and the + * current-goal read hold real cross-domain adaptation); * the route forwards each adapter result verbatim, mirroring v1's thin handler. * `create`, `fork`, and child creation publish `event.session.created` on the * core event bus, matching v1. @@ -101,6 +103,7 @@ import { createSessionRequestSchema, emptySessionUsage, forkSessionRequestSchema, + getSessionGoalResponseSchema, listSessionChildrenResponseSchema, pageResponseSchema, sessionAbortResponseSchema, @@ -918,6 +921,35 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void statusRoute.handler as Parameters[2], ); + const goalRoute = defineRoute( + { + method: 'GET', + path: '/sessions/{session_id}/goal', + params: sessionIdParamSchema, + success: { data: getSessionGoalResponseSchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: { detailsSchema }, + [ErrorCode.SESSION_NOT_FOUND]: {}, + }, + description: 'Get the current session goal (null when none is active)', + tags: ['sessions'], + }, + async (req, reply) => { + try { + const { session_id } = req.params; + const goal = await core.accessor.get(ISessionLegacyService).goal(session_id); + reply.send(okEnvelope(goal, req.id)); + } catch (error) { + sendMappedError(reply, req.id, error); + } + }, + ); + app.get( + goalRoute.path, + goalRoute.options, + goalRoute.handler as Parameters[2], + ); + const sessionWarningsRoute = defineRoute( { method: 'GET', diff --git a/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap b/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap index 8c304beab6..d5952aaa63 100644 --- a/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap +++ b/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap @@ -112,6 +112,10 @@ exports[`API surface snapshot > matches the documented v2 route table and meta e "GET", "/api/v1/sessions/{session_id}/fs/{*}", ], + [ + "GET", + "/api/v1/sessions/{session_id}/goal", + ], [ "GET", "/api/v1/sessions/{session_id}/messages", diff --git a/packages/kap-server/test/sessions.test.ts b/packages/kap-server/test/sessions.test.ts index e0a2b33bd1..4b5d704cb7 100644 --- a/packages/kap-server/test/sessions.test.ts +++ b/packages/kap-server/test/sessions.test.ts @@ -330,6 +330,25 @@ describe('server-v2 /api/v1/sessions', () => { expect(after.body.data.permission).toBe('yolo'); }); + it('returns the current goal via GET /goal', async () => { + const cwd = home as string; + const created = await postJson('/api/v1/sessions', { metadata: { cwd } }); + const id = created.body.data.id; + + const before = await getJson(`/api/v1/sessions/${id}/goal`); + expect(before.body.data).toBeNull(); + + await postJson(`/api/v1/sessions/${id}/profile`, { + agent_config: { goal_objective: 'fix all lint warnings' }, + }); + + const after = await getJson<{ objective: string; status: string } | null>( + `/api/v1/sessions/${id}/goal`, + ); + expect(after.body.data?.objective).toBe('fix all lint warnings'); + expect(after.body.data?.status).toBe('active'); + }); + it('archives a session via :archive and reflects archived flag on get', async () => { const cwd = home as string; const created = await postJson('/api/v1/sessions', { metadata: { cwd } }); diff --git a/packages/protocol/src/rest/session.ts b/packages/protocol/src/rest/session.ts index bcd84181a2..2b4dec6760 100644 --- a/packages/protocol/src/rest/session.ts +++ b/packages/protocol/src/rest/session.ts @@ -17,6 +17,7 @@ import { z } from 'zod'; +import { goalSnapshotSchema } from '../events'; import { messageSchema } from '../message'; import { cursorQuerySchema, pageResponseSchema } from '../pagination'; import { @@ -120,6 +121,12 @@ export const sessionStatusResponseSchema = z.object({ }); export type SessionStatusResponse = z.infer; +// GET /sessions/{id}/goal — the session's current goal snapshot (camelCase, +// same shape as the `goal.updated` WS event payload), or null when none is +// active. +export const getSessionGoalResponseSchema = goalSnapshotSchema.nullable(); +export type GetSessionGoalResponse = z.infer; + export const sessionWarningSchema = z.object({ code: z.string(), message: z.string(),