@@ -649,6 +649,41 @@ describe("hydrateState — session runtime model state", () => {
|
||||
expect(systemResult.system).toEqual(defaultState.system);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hydrateState — route MCP status freshness", () => {
|
||||
it("keeps newer live MCP state when force-merging an older route snapshot", () => {
|
||||
const routeHistory: MCPAttachmentHistory = {
|
||||
version: 1,
|
||||
current: {
|
||||
attachment_attempt_id: "attempt-route",
|
||||
started_at: "2026-06-10T00:00:00.000Z",
|
||||
servers: [{ name: "route", status: "connected" }],
|
||||
},
|
||||
};
|
||||
const liveHistory: MCPAttachmentHistory = {
|
||||
version: 1,
|
||||
current: {
|
||||
attachment_attempt_id: "attempt-live",
|
||||
started_at: "2026-06-11T00:00:00.000Z",
|
||||
updated_at: "2026-06-11T00:01:00.000Z",
|
||||
servers: [{ name: "live", status: "active" }],
|
||||
},
|
||||
};
|
||||
const result = produce(makeAppDraft(), (draft: Draft<AppState>) => {
|
||||
draft.sessionMcpStatus.bySessionId["session-1"] = liveHistory;
|
||||
hydrateState(
|
||||
draft,
|
||||
{
|
||||
sessionMcpStatus: { bySessionId: { "session-1": routeHistory } },
|
||||
} as unknown as Partial<AppState>,
|
||||
{ activeSessionId: "session-1", forceMergeSessionId: "session-1" },
|
||||
);
|
||||
});
|
||||
|
||||
expect(result.sessionMcpStatus.bySessionId["session-1"]).toEqual(liveHistory);
|
||||
});
|
||||
});
|
||||
|
||||
it.each([true, false])(
|
||||
"turn hydration marks a session only when it is force-merged",
|
||||
(forceMerge) => {
|
||||
|
||||
@@ -15,6 +15,11 @@ import {
|
||||
reconcileActiveTurnAfterHydrationDraft,
|
||||
seedSettledSessionBoundaries,
|
||||
} from "@/lib/state/slices/session/turn-actions";
|
||||
import type { MCPAttachmentHistory } from "@/lib/state/slices/session-runtime/types";
|
||||
import {
|
||||
readMcpAttachmentHistory,
|
||||
shouldReplaceMcpAttachmentHistory,
|
||||
} from "@/lib/state/slices/session-runtime/mcp-attachment-reconciliation";
|
||||
import { preserveOmittedExecutorFields } from "@/lib/kanban/map-task";
|
||||
import { deepMerge, mergeSessionMap, mergeLoadingState } from "./merge-strategies";
|
||||
|
||||
@@ -453,6 +458,28 @@ function hydrateSessionRuntime(
|
||||
mergeSessionMap(target.bySessionId, source.bySessionId, activeSessionId, forceMergeSessionId);
|
||||
};
|
||||
|
||||
/** Hydrate MCP history without allowing a stale forced route snapshot to regress live evidence. */
|
||||
const mergeHydratedMcpStatus = (source: Record<string, unknown> | undefined): void => {
|
||||
if (!source) return;
|
||||
const target = draft.sessionMcpStatus.bySessionId as unknown as Record<
|
||||
string,
|
||||
MCPAttachmentHistory
|
||||
>;
|
||||
for (const [sessionId, rawHistory] of Object.entries(source)) {
|
||||
const shouldForceMerge = forceMergeSessionId === sessionId;
|
||||
if (!shouldForceMerge && sessionId === activeSessionId) continue;
|
||||
|
||||
const incoming = readMcpAttachmentHistory(rawHistory);
|
||||
if (!incoming) continue;
|
||||
|
||||
const existing = target[sessionId];
|
||||
if (!shouldForceMerge && existing) continue;
|
||||
if (shouldReplaceMcpAttachmentHistory(existing, incoming)) {
|
||||
target[sessionId] = incoming;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (state.terminal) deepMerge(draft.terminal, state.terminal);
|
||||
if (state.shell) {
|
||||
mergeSessionMap(
|
||||
@@ -482,7 +509,9 @@ function hydrateSessionRuntime(
|
||||
Object.assign(draft.environmentIdBySessionId, state.environmentIdBySessionId);
|
||||
}
|
||||
mergeBySession("sessionModels");
|
||||
mergeBySession("sessionMcpStatus");
|
||||
mergeHydratedMcpStatus(
|
||||
state.sessionMcpStatus?.bySessionId as Record<string, unknown> | undefined,
|
||||
);
|
||||
if (state.agents) deepMerge(draft.agents, state.agents);
|
||||
mergeBySession("prepareProgress");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { parseStrictRfc3339Timestamp } from "@/lib/utils/strict-timestamp";
|
||||
import type {
|
||||
MCPAttachmentHistory,
|
||||
MCPAttachmentServer,
|
||||
MCPAttachmentStatus,
|
||||
MCPToolSummary,
|
||||
} from "./types";
|
||||
|
||||
const MCP_ATTACHMENT_STATUS_VALUES: readonly MCPAttachmentStatus[] = [
|
||||
"unknown",
|
||||
"delivered",
|
||||
"connected",
|
||||
"active",
|
||||
"failed",
|
||||
"filtered",
|
||||
"unavailable",
|
||||
];
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function isOptionalString(value: unknown): boolean {
|
||||
return value === undefined || value === null || typeof value === "string";
|
||||
}
|
||||
|
||||
function isOptionalBoolean(value: unknown): boolean {
|
||||
return value === undefined || value === null || typeof value === "boolean";
|
||||
}
|
||||
|
||||
function isOptionalNonNegativeInteger(value: unknown): boolean {
|
||||
return (
|
||||
value === undefined ||
|
||||
value === null ||
|
||||
(typeof value === "number" && Number.isInteger(value) && value >= 0)
|
||||
);
|
||||
}
|
||||
|
||||
function isOptionalTimestamp(value: unknown): boolean {
|
||||
return (
|
||||
value === undefined ||
|
||||
value === null ||
|
||||
(typeof value === "string" && parseStrictRfc3339Timestamp(value) !== null)
|
||||
);
|
||||
}
|
||||
|
||||
function isOptionalStringFields(
|
||||
value: Record<string, unknown>,
|
||||
fields: readonly string[],
|
||||
): boolean {
|
||||
return fields.every((field) => isOptionalString(value[field]));
|
||||
}
|
||||
|
||||
function isMcpAttachmentSource(value: unknown): boolean {
|
||||
return value === undefined || value === null || value === "kandev" || value === "profile";
|
||||
}
|
||||
|
||||
function isMcpAttachmentStatus(value: unknown): value is MCPAttachmentStatus {
|
||||
return (
|
||||
typeof value === "string" && MCP_ATTACHMENT_STATUS_VALUES.includes(value as MCPAttachmentStatus)
|
||||
);
|
||||
}
|
||||
|
||||
function isValidMcpToolSummary(value: unknown): value is MCPToolSummary {
|
||||
if (!isRecord(value) || !isNonEmptyString(value.name)) return false;
|
||||
return (
|
||||
isOptionalString(value.description) &&
|
||||
isOptionalBoolean(value.input_schema_truncated) &&
|
||||
isOptionalNonNegativeInteger(value.estimated_tokens)
|
||||
);
|
||||
}
|
||||
|
||||
function isValidMcpAttachmentServerFields(value: Record<string, unknown>): boolean {
|
||||
return (
|
||||
isMcpAttachmentSource(value.source) &&
|
||||
isOptionalStringFields(value, [
|
||||
"transport",
|
||||
"target",
|
||||
"reason_code",
|
||||
"summary",
|
||||
"connection_id",
|
||||
"tool_token_estimator",
|
||||
]) &&
|
||||
isOptionalTimestamp(value.tools_listed_at) &&
|
||||
isOptionalNonNegativeInteger(value.tool_count) &&
|
||||
isOptionalBoolean(value.tool_catalog_truncated) &&
|
||||
(value.tools === undefined ||
|
||||
value.tools === null ||
|
||||
(Array.isArray(value.tools) && value.tools.every(isValidMcpToolSummary)))
|
||||
);
|
||||
}
|
||||
|
||||
function isValidMcpAttachmentServer(value: unknown): value is MCPAttachmentServer {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
isNonEmptyString(value.name) &&
|
||||
isMcpAttachmentStatus(value.status) &&
|
||||
isValidMcpAttachmentServerFields(value)
|
||||
);
|
||||
}
|
||||
|
||||
function isValidMcpAttachmentAttempt(value: unknown): value is MCPAttachmentHistory["current"] {
|
||||
if (!isRecord(value)) return false;
|
||||
if (!isNonEmptyString(value.attachment_attempt_id)) return false;
|
||||
if (
|
||||
typeof value.started_at !== "string" ||
|
||||
parseStrictRfc3339Timestamp(value.started_at) === null
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (!isOptionalTimestamp(value.updated_at)) return false;
|
||||
return (
|
||||
value.servers === undefined ||
|
||||
value.servers === null ||
|
||||
(Array.isArray(value.servers) && value.servers.every(isValidMcpAttachmentServer))
|
||||
);
|
||||
}
|
||||
|
||||
function isValidMcpAttachmentHistory(value: unknown): value is MCPAttachmentHistory {
|
||||
if (!isRecord(value) || value.version !== 1 || !isValidMcpAttachmentAttempt(value.current)) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
value.previous === undefined ||
|
||||
value.previous === null ||
|
||||
(Array.isArray(value.previous) && value.previous.every(isValidMcpAttachmentAttempt))
|
||||
);
|
||||
}
|
||||
|
||||
/** Returns the validated persisted MCP projection, or null for unsupported data. */
|
||||
export function readMcpAttachmentHistory(value: unknown): MCPAttachmentHistory | null {
|
||||
return isValidMcpAttachmentHistory(value) ? value : null;
|
||||
}
|
||||
|
||||
function mcpHistoryFreshness(history: unknown): bigint | null {
|
||||
if (!isRecord(history) || !isRecord(history.current)) return null;
|
||||
const timestamp = history.current.updated_at ?? history.current.started_at;
|
||||
return typeof timestamp === "string" ? parseStrictRfc3339Timestamp(timestamp) : null;
|
||||
}
|
||||
|
||||
/** Whether an incoming valid history is newer than the existing live evidence. */
|
||||
export function shouldReplaceMcpAttachmentHistory(
|
||||
existing: unknown,
|
||||
incoming: MCPAttachmentHistory,
|
||||
): boolean {
|
||||
if (!existing) return true;
|
||||
const incomingFreshness = mcpHistoryFreshness(incoming);
|
||||
if (incomingFreshness === null) return false;
|
||||
const existingFreshness = mcpHistoryFreshness(existing);
|
||||
return existingFreshness === null || incomingFreshness > existingFreshness;
|
||||
}
|
||||
@@ -19,6 +19,10 @@ import { purgeSessionRuntimeState } from "@/lib/state/slices/session-runtime/ses
|
||||
import { mergeTaskSession } from "./session-merge";
|
||||
import { syncEnvironmentMapping, syncPrepareProgress } from "./session-environment-sync";
|
||||
import type { SessionRuntimeSliceState } from "@/lib/state/slices/session-runtime/types";
|
||||
import {
|
||||
readMcpAttachmentHistory,
|
||||
shouldReplaceMcpAttachmentHistory,
|
||||
} from "@/lib/state/slices/session-runtime/mcp-attachment-reconciliation";
|
||||
import { getPlanLastSeen, setPlanLastSeen } from "@/lib/local-storage";
|
||||
import {
|
||||
getWalkthroughLastSeen,
|
||||
@@ -122,6 +126,17 @@ function mergeTaskSessionSnapshot(
|
||||
};
|
||||
}
|
||||
|
||||
function reconcileMcpAttachmentHistory(
|
||||
draft: SessionSliceState & SessionRuntimeSliceState,
|
||||
session: TaskSession,
|
||||
): void {
|
||||
const incoming = readMcpAttachmentHistory(session.metadata?.mcp_attachment_state);
|
||||
if (!incoming) return;
|
||||
const existing = draft.sessionMcpStatus.bySessionId[session.id];
|
||||
if (!shouldReplaceMcpAttachmentHistory(existing, incoming)) return;
|
||||
draft.sessionMcpStatus.bySessionId[session.id] = incoming;
|
||||
}
|
||||
|
||||
// Settled states are defined once in turn-actions (SETTLED_SESSION_STATES /
|
||||
// isSettledSessionState); this file must use the shared predicate so every
|
||||
// settled-boundary path — hydration seeding, session updates, and the WS
|
||||
@@ -608,6 +623,10 @@ function buildTaskSessionReconciliationActions(set: ImmerSet) {
|
||||
draft.taskSessions.items[session.id] = session;
|
||||
syncEnvironmentMapping(draft, session.id, session.task_environment_id);
|
||||
syncPrepareProgress(draft, session);
|
||||
reconcileMcpAttachmentHistory(
|
||||
draft as unknown as SessionSliceState & SessionRuntimeSliceState,
|
||||
session,
|
||||
);
|
||||
reconcileActiveTurnForIdleSession(draft, session);
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { create } from "zustand";
|
||||
import { immer } from "zustand/middleware/immer";
|
||||
import { createSessionSlice } from "./session-slice";
|
||||
import { createSessionRuntimeSlice } from "../session-runtime/session-runtime-slice";
|
||||
import type { SessionSlice } from "./types";
|
||||
import type { MCPAttachmentHistory, SessionRuntimeSlice } from "../session-runtime/types";
|
||||
import { sessionId as toSessionId, taskId as toTaskId, type TaskSession } from "@/lib/types/http";
|
||||
|
||||
type CombinedSlice = SessionSlice & SessionRuntimeSlice;
|
||||
|
||||
function makeStore() {
|
||||
return create<CombinedSlice>()(
|
||||
immer((set) => ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
...(createSessionSlice as any)(set),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
...(createSessionRuntimeSlice as any)(set),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
const TASK_ID = toTaskId("task-1");
|
||||
const SESSION_ID = toSessionId("session-1");
|
||||
const SIBLING_SESSION_ID = toSessionId("session-2");
|
||||
const SESSION_TIMESTAMP = "2026-04-20T00:00:00Z";
|
||||
const MIDDLE_TIMESTAMP = "2026-04-20T00:01:00Z";
|
||||
const LATEST_TIMESTAMP = "2026-04-20T00:02:00Z";
|
||||
|
||||
function makeSession(id: string, metadata?: Record<string, unknown>): TaskSession {
|
||||
return {
|
||||
id: toSessionId(id),
|
||||
task_id: TASK_ID,
|
||||
state: "RUNNING",
|
||||
started_at: SESSION_TIMESTAMP,
|
||||
updated_at: SESSION_TIMESTAMP,
|
||||
...(metadata ? { metadata } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function makeHistory({
|
||||
attemptId = "attempt-persisted",
|
||||
startedAt = "2026-04-20T00:00:00Z",
|
||||
updatedAt,
|
||||
}: {
|
||||
attemptId?: string;
|
||||
startedAt?: string;
|
||||
updatedAt?: string;
|
||||
} = {}): MCPAttachmentHistory {
|
||||
const current: MCPAttachmentHistory["current"] = {
|
||||
attachment_attempt_id: attemptId,
|
||||
started_at: startedAt,
|
||||
servers: [{ name: "kandev", status: "active" }],
|
||||
};
|
||||
if (updatedAt !== undefined) current.updated_at = updatedAt;
|
||||
return { version: 1, current };
|
||||
}
|
||||
|
||||
function withCurrentPatch(patch: Record<string, unknown>): Record<string, unknown> {
|
||||
const history = makeHistory();
|
||||
return { ...history, current: { ...history.current, ...patch } };
|
||||
}
|
||||
|
||||
describe("setTaskSessionsForTask MCP history", () => {
|
||||
it.each([
|
||||
["started_at", makeHistory()],
|
||||
[
|
||||
"updated_at",
|
||||
makeHistory({
|
||||
startedAt: MIDDLE_TIMESTAMP,
|
||||
updatedAt: LATEST_TIMESTAMP,
|
||||
}),
|
||||
],
|
||||
])("restores valid attachment history using %s freshness", (_freshness, history) => {
|
||||
const store = makeStore();
|
||||
|
||||
store
|
||||
.getState()
|
||||
.setTaskSessionsForTask(
|
||||
TASK_ID,
|
||||
[makeSession("session-1", { mcp_attachment_state: history })],
|
||||
{},
|
||||
);
|
||||
|
||||
expect(store.getState().sessionMcpStatus.bySessionId[SESSION_ID]).toEqual(history);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setTaskSessionsForTask MCP history freshness", () => {
|
||||
it.each([
|
||||
["older", makeHistory({ attemptId: "attempt-older", startedAt: MIDDLE_TIMESTAMP })],
|
||||
[
|
||||
"equal",
|
||||
makeHistory({
|
||||
attemptId: "attempt-equal",
|
||||
startedAt: LATEST_TIMESTAMP,
|
||||
updatedAt: LATEST_TIMESTAMP,
|
||||
}),
|
||||
],
|
||||
])("keeps newer live evidence when the incoming snapshot is %s", (_age, incoming) => {
|
||||
const store = makeStore();
|
||||
const live = makeHistory({
|
||||
attemptId: "attempt-live",
|
||||
startedAt: LATEST_TIMESTAMP,
|
||||
updatedAt: LATEST_TIMESTAMP,
|
||||
});
|
||||
store.getState().setSessionMCPStatus(SESSION_ID, live);
|
||||
|
||||
store
|
||||
.getState()
|
||||
.setTaskSessionsForTask(
|
||||
TASK_ID,
|
||||
[makeSession("session-1", { mcp_attachment_state: incoming })],
|
||||
{},
|
||||
);
|
||||
|
||||
expect(store.getState().sessionMcpStatus.bySessionId[SESSION_ID]).toEqual(live);
|
||||
});
|
||||
|
||||
it("replaces older stored history with a strictly newer snapshot", () => {
|
||||
const store = makeStore();
|
||||
const stored = makeHistory({
|
||||
attemptId: "attempt-stored",
|
||||
startedAt: SESSION_TIMESTAMP,
|
||||
updatedAt: MIDDLE_TIMESTAMP,
|
||||
});
|
||||
const incoming = makeHistory({
|
||||
attemptId: "attempt-new",
|
||||
startedAt: SESSION_TIMESTAMP,
|
||||
updatedAt: LATEST_TIMESTAMP,
|
||||
});
|
||||
store.getState().setSessionMCPStatus(SESSION_ID, stored);
|
||||
|
||||
store
|
||||
.getState()
|
||||
.setTaskSessionsForTask(
|
||||
TASK_ID,
|
||||
[makeSession("session-1", { mcp_attachment_state: incoming })],
|
||||
{},
|
||||
);
|
||||
|
||||
expect(store.getState().sessionMcpStatus.bySessionId[SESSION_ID]).toEqual(incoming);
|
||||
});
|
||||
|
||||
it("replaces stored history when its freshness timestamp is invalid", () => {
|
||||
const store = makeStore();
|
||||
const stored = makeHistory({ attemptId: "attempt-invalid", startedAt: "not-a-timestamp" });
|
||||
const incoming = makeHistory({ attemptId: "attempt-valid", startedAt: MIDDLE_TIMESTAMP });
|
||||
store.getState().setSessionMCPStatus(SESSION_ID, stored);
|
||||
|
||||
store
|
||||
.getState()
|
||||
.setTaskSessionsForTask(
|
||||
TASK_ID,
|
||||
[makeSession("session-1", { mcp_attachment_state: incoming })],
|
||||
{},
|
||||
);
|
||||
|
||||
expect(store.getState().sessionMcpStatus.bySessionId[SESSION_ID]).toEqual(incoming);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setTaskSessionsForTask MCP history isolation", () => {
|
||||
it("updates only the session that owns restored history", () => {
|
||||
const store = makeStore();
|
||||
const ownerHistory = makeHistory({
|
||||
attemptId: "attempt-owner",
|
||||
startedAt: MIDDLE_TIMESTAMP,
|
||||
});
|
||||
const siblingHistory = makeHistory({
|
||||
attemptId: "attempt-sibling",
|
||||
startedAt: LATEST_TIMESTAMP,
|
||||
});
|
||||
store.getState().setSessionMCPStatus(SIBLING_SESSION_ID, siblingHistory);
|
||||
|
||||
store
|
||||
.getState()
|
||||
.setTaskSessionsForTask(
|
||||
TASK_ID,
|
||||
[
|
||||
makeSession("session-1", { mcp_attachment_state: ownerHistory }),
|
||||
makeSession("session-2"),
|
||||
],
|
||||
{},
|
||||
);
|
||||
|
||||
expect(store.getState().sessionMcpStatus.bySessionId[SESSION_ID]).toEqual(ownerHistory);
|
||||
expect(store.getState().sessionMcpStatus.bySessionId[SIBLING_SESSION_ID]).toEqual(
|
||||
siblingHistory,
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts additive fields in a valid history", () => {
|
||||
const store = makeStore();
|
||||
const base = makeHistory();
|
||||
const history = {
|
||||
...base,
|
||||
report_version: "backend-1",
|
||||
current: {
|
||||
...base.current,
|
||||
execution_id: "execution-1",
|
||||
servers: [{ ...base.current.servers![0], server_revision: 3 }],
|
||||
},
|
||||
};
|
||||
|
||||
store
|
||||
.getState()
|
||||
.setTaskSessionsForTask(
|
||||
TASK_ID,
|
||||
[makeSession("session-1", { mcp_attachment_state: history })],
|
||||
{},
|
||||
);
|
||||
|
||||
expect(store.getState().sessionMcpStatus.bySessionId[SESSION_ID]).toEqual(history);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setTaskSessionsForTask MCP history validation", () => {
|
||||
it("treats null optional fields as absent", () => {
|
||||
const store = makeStore();
|
||||
const base = makeHistory();
|
||||
const history = {
|
||||
...base,
|
||||
current: {
|
||||
...base.current,
|
||||
updated_at: null,
|
||||
servers: [
|
||||
{
|
||||
...base.current.servers![0],
|
||||
source: null,
|
||||
transport: null,
|
||||
target: null,
|
||||
reason_code: null,
|
||||
summary: null,
|
||||
connection_id: null,
|
||||
tools_listed_at: null,
|
||||
tool_count: null,
|
||||
tools: null,
|
||||
tool_catalog_truncated: null,
|
||||
tool_token_estimator: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
previous: null,
|
||||
};
|
||||
|
||||
store
|
||||
.getState()
|
||||
.setTaskSessionsForTask(
|
||||
TASK_ID,
|
||||
[makeSession("session-1", { mcp_attachment_state: history })],
|
||||
{},
|
||||
);
|
||||
|
||||
expect(store.getState().sessionMcpStatus.bySessionId[SESSION_ID]).toEqual(history);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["unsupported version", { ...makeHistory(), version: 2 }],
|
||||
["missing current attempt", { version: 1 }],
|
||||
["empty attempt identity", withCurrentPatch({ attachment_attempt_id: " " })],
|
||||
["invalid started_at", withCurrentPatch({ started_at: "2026-02-30T00:00:00Z" })],
|
||||
["invalid updated_at", withCurrentPatch({ updated_at: "tomorrow" })],
|
||||
[
|
||||
"invalid server timestamp",
|
||||
withCurrentPatch({
|
||||
servers: [{ name: "kandev", status: "active", tools_listed_at: "later" }],
|
||||
}),
|
||||
],
|
||||
["empty server name", withCurrentPatch({ servers: [{ name: " ", status: "active" }] })],
|
||||
[
|
||||
"unknown server status",
|
||||
withCurrentPatch({ servers: [{ name: "kandev", status: "online" }] }),
|
||||
],
|
||||
["non-array previous attempts", { ...makeHistory(), previous: {} }],
|
||||
[
|
||||
"invalid previous attempt",
|
||||
{ ...makeHistory(), previous: [{ ...makeHistory().current, attachment_attempt_id: "" }] },
|
||||
],
|
||||
])("ignores %s metadata and preserves existing statuses", (_reason, invalidHistory) => {
|
||||
const store = makeStore();
|
||||
const ownerHistory = makeHistory({ attemptId: "attempt-owner" });
|
||||
const siblingHistory = makeHistory({ attemptId: "attempt-sibling" });
|
||||
store.getState().setSessionMCPStatus(SESSION_ID, ownerHistory);
|
||||
store.getState().setSessionMCPStatus(SIBLING_SESSION_ID, siblingHistory);
|
||||
|
||||
store
|
||||
.getState()
|
||||
.setTaskSessionsForTask(
|
||||
TASK_ID,
|
||||
[
|
||||
makeSession("session-1", { mcp_attachment_state: invalidHistory }),
|
||||
makeSession("session-2"),
|
||||
],
|
||||
{},
|
||||
);
|
||||
|
||||
expect(store.getState().sessionMcpStatus.bySessionId[SESSION_ID]).toEqual(ownerHistory);
|
||||
expect(store.getState().sessionMcpStatus.bySessionId[SIBLING_SESSION_ID]).toEqual(
|
||||
siblingHistory,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
---
|
||||
created: 2026-09-09
|
||||
status: done
|
||||
requirements:
|
||||
- REQ-PLATFORM-MCP-SESSION-OBSERVABILITY-001
|
||||
system_design:
|
||||
- ../../specs/platform/system-design/mcp-session-observability.md
|
||||
legacy_specs: []
|
||||
---
|
||||
|
||||
# Implementation Plan: Restore Session MCP Status
|
||||
|
||||
## Overview
|
||||
|
||||
Issue #3547 leaves persisted MCP status unavailable after some task switches.
|
||||
The repair adds validated, freshness-aware restoration to session-list and
|
||||
forced task-detail route reconciliation. One frontend work order owns the code
|
||||
and regression tests.
|
||||
|
||||
## Confirmed root cause
|
||||
|
||||
`setTaskSessionsForTask` merges the returned session rows, but it does not read
|
||||
`metadata.mcp_attachment_state`. Boot hydration and live WebSocket events can
|
||||
populate `sessionMcpStatus.bySessionId`, but forced route hydration could also
|
||||
replace newer live MCP evidence with an older response.
|
||||
|
||||
The new `set-task-sessions-mcp.test.ts` file will contain the permanent
|
||||
regression test. Its restoration case fails on the current code because the
|
||||
status entry remains undefined.
|
||||
|
||||
## Scope
|
||||
|
||||
### In scope
|
||||
|
||||
- Restore valid version-1 MCP attachment history from task session metadata.
|
||||
- Reject malformed and unsupported histories.
|
||||
- Keep newer live evidence when a delayed session-list snapshot arrives.
|
||||
- Keep newer live evidence when a delayed forced route snapshot arrives.
|
||||
- Keep every sibling session status unchanged.
|
||||
|
||||
### Out of scope
|
||||
|
||||
- Backend persistence or response changes.
|
||||
- Changes to WebSocket event production.
|
||||
- Changes to the MCP status layout or user-facing copy.
|
||||
- Changes to MCP evidence semantics or schema versioning.
|
||||
|
||||
## Technical approach
|
||||
|
||||
### Runtime validation and freshness
|
||||
|
||||
Add shared validation and freshness helpers in the session-runtime slice. Reuse
|
||||
`parseStrictRfc3339Timestamp` for all timestamps in the frontend projection.
|
||||
Accept additive fields and JSON `null` for optional values, but reject invalid
|
||||
required fields and unknown status values.
|
||||
|
||||
During `setTaskSessionsForTask`, inspect each merged session's
|
||||
`metadata.mcp_attachment_state`. Compare the current attempt's `updated_at`,
|
||||
or its `started_at` fallback, with the stored history. Apply only a strictly
|
||||
newer valid snapshot, unless no valid stored history exists.
|
||||
|
||||
Update only `sessionMcpStatus.bySessionId[session.id]` in the same Immer
|
||||
transaction. Metadata absence or rejection leaves the existing entry and all
|
||||
sibling entries unchanged. Use the same comparator during forced task-detail
|
||||
route hydration so its response cannot regress a newer live entry.
|
||||
|
||||
## Tests
|
||||
|
||||
| Acceptance criterion | Evidence |
|
||||
| --- | --- |
|
||||
| `AC-PLATFORM-MCP-SESSION-OBSERVABILITY-001.10` | `set-task-sessions-mcp.test.ts` proves restoration with and without `updated_at`. |
|
||||
| `AC-PLATFORM-MCP-SESSION-OBSERVABILITY-001.11` | Session-list and route-hydration tests prove that older and equal snapshots do not replace live evidence. |
|
||||
| `AC-PLATFORM-MCP-SESSION-OBSERVABILITY-001.12` | Table tests cover malformed shapes, timestamps, names, statuses, and versions. A sibling-isolation test protects unrelated state. |
|
||||
|
||||
## E2E tests
|
||||
|
||||
No new Playwright test is required. This repair only normalizes data in an
|
||||
existing store and does not change a rendered interaction. Existing desktop
|
||||
and mobile MCP status tests prove that both surfaces consume this shared state.
|
||||
|
||||
Relevant existing files:
|
||||
|
||||
- `apps/web/e2e/tests/chat/mcp-status.spec.ts`
|
||||
- `apps/web/e2e/tests/chat/mobile-mcp-status.spec.ts`
|
||||
|
||||
## Work orders
|
||||
|
||||
- [x] [Task 01: Restore MCP status from session lists](task-01-restore-session-list-mcp-status.md)
|
||||
|
||||
## Verification results
|
||||
|
||||
- `cd apps && pnpm --filter @kandev/web exec vitest run lib/state/slices/session/set-task-sessions-mcp.test.ts lib/state/hydration/hydrator.test.ts` passed (46 tests).
|
||||
- `cd apps/web && pnpm run typecheck` passed.
|
||||
- Targeted ESLint passed for the session slice, reconciliation helper, hydrator, and both regression tests.
|
||||
- Related session reconciliation tests passed (3 files, 57 tests).
|
||||
|
||||
## Risks
|
||||
|
||||
- Excessive validation can reject additive safe fields. The guard validates
|
||||
known fields and permits unknown optional fields.
|
||||
- Timestamp ties can hide live details. Equal timestamps retain the current
|
||||
store entry.
|
||||
@@ -0,0 +1,100 @@
|
||||
---
|
||||
id: "01-restore-session-list-mcp-status"
|
||||
title: "Restore MCP status from session lists"
|
||||
status: done
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
requirements:
|
||||
- REQ-PLATFORM-MCP-SESSION-OBSERVABILITY-001
|
||||
acceptance_criteria:
|
||||
- AC-PLATFORM-MCP-SESSION-OBSERVABILITY-001.10
|
||||
- AC-PLATFORM-MCP-SESSION-OBSERVABILITY-001.11
|
||||
- AC-PLATFORM-MCP-SESSION-OBSERVABILITY-001.12
|
||||
system_design:
|
||||
- ../../specs/platform/system-design/mcp-session-observability.md
|
||||
---
|
||||
|
||||
# Task 01: Restore MCP Status From Session Lists
|
||||
|
||||
## Summary
|
||||
|
||||
Restore persisted MCP attachment history during task session-list and forced
|
||||
task-detail route reconciliation. Reject invalid metadata and prevent delayed
|
||||
snapshots from replacing newer live evidence.
|
||||
|
||||
## In scope
|
||||
|
||||
- Validate the version-1 frontend projection in
|
||||
`metadata.mcp_attachment_state`.
|
||||
- Compare incoming and stored current-attempt timestamps with the strict
|
||||
RFC3339 parser.
|
||||
- Reuse the validation and freshness comparator during forced task-detail
|
||||
route hydration.
|
||||
- Update only the MCP status entry for the owning session.
|
||||
- Add focused regression tests for restoration, rejection, freshness, route
|
||||
ordering, JSON null optionals, and sibling isolation.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Backend, persistence, or wire-contract changes.
|
||||
- WebSocket handler changes.
|
||||
- Component, layout, localization, or mobile interaction changes.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- A valid persisted history appears in the owning session's MCP status entry.
|
||||
- Equal or older snapshots never replace newer live evidence.
|
||||
- Invalid metadata does not change the owning entry or a sibling entry.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps && pnpm --filter @kandev/web exec vitest run lib/state/slices/session/set-task-sessions-mcp.test.ts lib/state/hydration/hydrator.test.ts
|
||||
cd apps/web && pnpm run typecheck
|
||||
cd apps && pnpm --filter @kandev/web exec eslint lib/state/slices/session/session-slice.ts lib/state/slices/session-runtime/mcp-attachment-reconciliation.ts lib/state/hydration/hydrator.ts lib/state/slices/session/set-task-sessions-mcp.test.ts lib/state/hydration/hydrator.test.ts
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/lib/state/slices/session/session-slice.ts`
|
||||
- `apps/web/lib/state/slices/session-runtime/mcp-attachment-reconciliation.ts`
|
||||
- `apps/web/lib/state/hydration/hydrator.ts`
|
||||
- `apps/web/lib/state/slices/session/set-task-sessions-mcp.test.ts`
|
||||
- `apps/web/lib/state/hydration/hydrator.test.ts`
|
||||
|
||||
## Dependencies
|
||||
|
||||
None.
|
||||
|
||||
## Risks
|
||||
|
||||
- The metadata type is `unknown` at runtime. A partial guard can admit invalid
|
||||
nested values.
|
||||
- Backend timestamps can use RFC3339 nanosecond precision. Millisecond parsing
|
||||
can misorder live and persisted evidence.
|
||||
|
||||
## Parallelism
|
||||
|
||||
`sequential`
|
||||
|
||||
## Inputs
|
||||
|
||||
- `REQ-PLATFORM-MCP-SESSION-OBSERVABILITY-001` and its new acceptance criteria.
|
||||
- The session MCP observability system design and ADR.
|
||||
- Existing strict timestamp parsing and session-list reconciliation patterns.
|
||||
|
||||
## Results
|
||||
|
||||
Implemented shared validated hydration of `metadata.mcp_attachment_state` during
|
||||
session-list reconciliation and freshness-guarded forced route hydration.
|
||||
Valid version-1 histories restore the owning session, additive fields and JSON
|
||||
null optionals remain supported, malformed histories are ignored, and older or
|
||||
equal snapshots cannot replace newer live evidence.
|
||||
|
||||
Verification passed:
|
||||
|
||||
- `cd apps && pnpm --filter @kandev/web exec vitest run lib/state/slices/session/set-task-sessions-mcp.test.ts lib/state/hydration/hydrator.test.ts` (46 tests)
|
||||
- `cd apps/web && pnpm run typecheck`
|
||||
- Targeted ESLint for the session slice, reconciliation helper, hydrator, and both regression tests.
|
||||
- Related session reconciliation tests (3 files, 57 tests)
|
||||
@@ -2,7 +2,7 @@
|
||||
status: active
|
||||
system: platform
|
||||
created: 2026-07-30
|
||||
updated: 2026-08-30
|
||||
updated: 2026-09-09
|
||||
owners:
|
||||
- Kandev
|
||||
---
|
||||
@@ -29,6 +29,9 @@ The chat toolbar currently derives its MCP list from agent-profile configuration
|
||||
- **AC-PLATFORM-MCP-SESSION-OBSERVABILITY-001.7:** **GIVEN** an Active Kandev server, **WHEN** a precise-pointer user hovers or focuses the MCP trigger, **THEN** the tooltip shows a green Kandev row and its Active status.
|
||||
- **AC-PLATFORM-MCP-SESSION-OBSERVABILITY-001.8:** **GIVEN** a precise-pointer user, **WHEN** they click the MCP trigger, **THEN** a wide dialog lists the active session's MCP servers.
|
||||
- **AC-PLATFORM-MCP-SESSION-OBSERVABILITY-001.9:** **GIVEN** a modern stateless MCP request, **WHEN** Kandev records protocol evidence, **THEN** the evidence belongs to the current attachment attempt without a fabricated connection ID or a connection-closed event at the end of the HTTP request.
|
||||
- **AC-PLATFORM-MCP-SESSION-OBSERVABILITY-001.10:** **GIVEN** a session list contains valid version-1 attachment history, **WHEN** the client loads the task, **THEN** the status surface restores it without a live event.
|
||||
- **AC-PLATFORM-MCP-SESSION-OBSERVABILITY-001.11:** **GIVEN** the client holds newer live evidence, **WHEN** an equal or older session snapshot arrives, **THEN** the status surface retains the live evidence.
|
||||
- **AC-PLATFORM-MCP-SESSION-OBSERVABILITY-001.12:** **GIVEN** session-list attachment history is malformed or unsupported, **WHEN** the client loads the task, **THEN** the client ignores it and retains other session statuses.
|
||||
|
||||
## System design
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ system: platform
|
||||
requirements:
|
||||
- REQ-PLATFORM-MCP-SESSION-OBSERVABILITY-001
|
||||
created: 2026-07-30
|
||||
updated: 2026-08-30
|
||||
updated: 2026-09-09
|
||||
owners:
|
||||
- Kandev
|
||||
---
|
||||
@@ -124,6 +124,46 @@ stable reason code plus a bounded sanitized summary.
|
||||
Raw ACP JSONL logging remains a development-only diagnostic and is not enabled
|
||||
by this feature.
|
||||
|
||||
## Frontend session-list reconciliation
|
||||
|
||||
Task-detail boot state restores attachment history during initial navigation.
|
||||
Later task switches use `ListTaskSessionSummariesResponse`, which includes
|
||||
public session metadata. Both paths supply the same persisted history, and
|
||||
forced task-detail route hydration uses the same freshness-aware merge.
|
||||
|
||||
`setTaskSessionsForTask` reads `metadata.mcp_attachment_state` in its existing
|
||||
Immer transaction. A runtime guard accepts only the known frontend projection:
|
||||
|
||||
- The value is an object with schema version `1`.
|
||||
- The current attempt has a nonempty `attachment_attempt_id`.
|
||||
- Each attempt has a valid RFC3339 `started_at` value.
|
||||
- Optional `updated_at` and `tools_listed_at` values are valid RFC3339 values
|
||||
when present. A JSON `null` value is treated as absent.
|
||||
- Each server has a nonempty name and a known attachment status.
|
||||
- Optional previous attempts satisfy the same attempt and server rules.
|
||||
|
||||
The guard permits unknown optional fields from the backend report. This rule
|
||||
keeps the frontend compatible with additive safe metadata.
|
||||
|
||||
The current attempt supplies the freshness value. The client uses `updated_at`
|
||||
when that field exists. Otherwise, the client uses `started_at`.
|
||||
|
||||
A valid incoming history initializes an empty store entry. It also replaces
|
||||
stored history with an invalid freshness value. For two valid values, only a
|
||||
strictly newer incoming value replaces the stored history. Equal values retain
|
||||
the stored history because it can contain live evidence from a WebSocket event.
|
||||
The same comparator applies when a forced task-detail route hydration merges a
|
||||
snapshot into the active session, so an equal or older route response cannot
|
||||
regress live evidence.
|
||||
|
||||
The reconciliation paths change only `sessionMcpStatus.bySessionId` for the
|
||||
session that owns the accepted history. Invalid or absent metadata does not
|
||||
clear live evidence. It does not change a sibling session.
|
||||
|
||||
Desktop and mobile surfaces already read the shared session-runtime slice.
|
||||
This repair does not change layout, navigation, touch behavior, or viewport
|
||||
behavior.
|
||||
|
||||
## Kandev tool catalog
|
||||
|
||||
After Kandev serves `tools/list`, the current attachment report includes a
|
||||
|
||||
Reference in New Issue
Block a user