Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/web-goal-card-refresh.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 14 additions & 0 deletions apps/kimi-web/src/api/daemon/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { KimiApiConfig } from '../config';
import { buildRestUrl, buildWsUrl } from '../config';
import type {
AppConfig,
AppGoal,
AppMessage,
AppMessageRole,
AppModel,
Expand Down Expand Up @@ -40,6 +41,7 @@ import {
toAppConfig,
toAppEvent,
toAppFsEntry,
toAppGoal,
toAppMessage,
toAppModel,
toAppProvider,
Expand All @@ -63,6 +65,7 @@ import type {
WireFsBrowseResult,
WireFsEntry,
WireFsHomeResult,
WireGoalSnapshot,
WireMessage,
WireModel,
WireOAuthCancelResult,
Expand Down Expand Up @@ -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<AppGoal | null> {
const data = await this.http.get<WireGoalSnapshot | null>(
`/sessions/${encodeURIComponent(sessionId)}/goal`,
);
return toAppGoal(data);
}

async getSessionWarnings(sessionId: string): Promise<WireSessionWarning[]> {
const data = await this.http.get<WireSessionWarningsResponse>(
`/sessions/${encodeURIComponent(sessionId)}/warnings`,
Expand Down
9 changes: 9 additions & 0 deletions apps/kimi-web/src/api/daemon/eventReducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ export interface KimiClientState {
questionsBySession: Record<string, AppQuestionRequest[]>;
tasksBySession: Record<string, AppTask[]>;
goalBySession: Record<string, AppGoal>;
/** 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<string, number>;
lastSeqBySession: Record<string, number>;
compactionBySession: Record<string, CompactionStatus>;
config?: AppConfig | null;
Expand All @@ -71,6 +75,7 @@ export function createInitialState(): KimiClientState {
questionsBySession: {},
tasksBySession: {},
goalBySession: {},
goalVersionBySession: {},
lastSeqBySession: {},
compactionBySession: {},
warnings: [],
Expand All @@ -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],
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion apps/kimi-web/src/api/daemon/mappers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -429,7 +429,7 @@ function recordNullableNumber(source: Record<string, unknown>, 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<string, unknown>;
const status = recordString(source, 'status');
Expand Down
25 changes: 25 additions & 0 deletions apps/kimi-web/src/api/daemon/wire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions apps/kimi-web/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -664,6 +664,8 @@ export interface KimiWebApi {
getSession(sessionId: string): Promise<AppSession>;
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<AppSession>;
getSessionStatus(sessionId: string): Promise<AppSessionRuntimeStatus>;
/** Current goal snapshot, or null when the session has no active goal. */
getSessionGoal(sessionId: string): Promise<AppGoal | null>;
getSessionWarnings(sessionId: string): Promise<AppSessionWarning[]>;
archiveSession(sessionId: string): Promise<{ archived: true }>;
restoreSession(sessionId: string): Promise<AppSession>;
Expand Down
3 changes: 3 additions & 0 deletions apps/kimi-web/src/composables/client/useWorkspaceState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,7 @@ export interface UseWorkspaceStateDeps {
reopenSession: (sessionId: string) => Promise<SyncSessionResult>;
hasLoadedMessages: (sessionId: string) => boolean;
refreshSessionStatus: (sessionId: string) => Promise<void>;
refreshSessionGoal: (sessionId: string) => Promise<void>;
persistSessionProfile: (patch: PersistSessionProfilePatch, sessionId?: string) => Promise<void>;
mergedWorkspaces: ComputedRef<AppWorkspace[]>;
/** Sidebar-facing workspaces in the user's (dragged) display order. */
Expand Down Expand Up @@ -271,6 +272,7 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
reopenSession,
hasLoadedMessages,
refreshSessionStatus,
refreshSessionGoal,
persistSessionProfile,
mergedWorkspaces,
workspacesView,
Expand Down Expand Up @@ -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);
}
Expand Down
35 changes: 35 additions & 0 deletions apps/kimi-web/src/composables/useKimiWebClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -649,6 +649,38 @@ async function refreshSessionStatus(sessionId: string): Promise<void> {
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<void> {
// 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.
Comment thread
liruifengv marked this conversation as resolved.
Comment thread
liruifengv marked this conversation as resolved.
}
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;
Comment thread
liruifengv marked this conversation as resolved.
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
Expand Down Expand Up @@ -758,6 +790,7 @@ function applyEvent(event: ReturnType<typeof toAppEvent>, sessionId: string, seq
questionsBySession: rawState.questionsBySession,
tasksBySession: rawState.tasksBySession,
goalBySession: rawState.goalBySession,
goalVersionBySession: rawState.goalVersionBySession,
lastSeqBySession: rawState.lastSeqBySession,
compactionBySession: rawState.compactionBySession,
config: rawState.config,
Expand All @@ -773,6 +806,7 @@ function applyEvent(event: ReturnType<typeof toAppEvent>, 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;
Expand Down Expand Up @@ -2394,6 +2428,7 @@ const workspaceState = useWorkspaceState(rawState, {
reopenSession,
hasLoadedMessages,
refreshSessionStatus,
refreshSessionGoal,
persistSessionProfile,
mergedWorkspaces,
workspacesView,
Expand Down
77 changes: 77 additions & 0 deletions apps/kimi-web/test/daemon-client.test.ts
Original file line number Diff line number Diff line change
@@ -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',
);
});
});
1 change: 1 addition & 0 deletions apps/kimi-web/test/workspace-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => []),
Expand Down
19 changes: 13 additions & 6 deletions packages/agent-core-v2/src/app/sessionLegacy/sessionLegacy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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';

Expand All @@ -47,6 +52,8 @@ export interface ISessionLegacyService {

updateProfile(sessionId: string, body: UpdateSessionProfileRequest): Promise<SessionWireFields>;
status(sessionId: string): Promise<SessionStatusResponse>;
/** Current goal snapshot, or null when the session has no active goal. */
Comment thread
liruifengv marked this conversation as resolved.
goal(sessionId: string): Promise<GoalSnapshot | null>;
}

export const ISessionLegacyService: ServiceIdentifier<ISessionLegacyService> =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,20 @@
* 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
* are called by the edge route directly. No business logic is duplicated here;
* 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';
Expand Down Expand Up @@ -209,6 +214,11 @@ export class SessionLegacyService implements ISessionLegacyService {
context_usage: maxTokens > 0 ? tokens / maxTokens : 0,
};
}

async goal(sessionId: string): Promise<GoalSnapshot | null> {
const agent = await this.resolveMainAgent(sessionId);
return agent.accessor.get(IAgentGoalService).getGoal().goal;
}
}

/**
Expand Down
Loading
Loading