fix(ui): preserve resumed session model labels (#2904)
This commit is contained in:
@@ -3083,6 +3083,23 @@ func (s *Service) handleSessionModelsEvent(ctx context.Context, payload *lifecyc
|
||||
zap.String("session_id", sessionID),
|
||||
zap.Error(err))
|
||||
}
|
||||
settled := configOptionsSettled(payload.Data.Data)
|
||||
session, err := s.repo.GetTaskSession(ctx, sessionID)
|
||||
if err != nil {
|
||||
s.logger.Warn("failed to load session before session model persistence",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.Error(err))
|
||||
return
|
||||
}
|
||||
if shouldDeferUnsettledStartupModelsEvent(session, settled) {
|
||||
// The session state read is optimistic. A concurrent transition out of
|
||||
// STARTING can cause a conservative defer, and the next live model event
|
||||
// corrects the client state without introducing a lock-order dependency.
|
||||
s.logger.Debug("deferring unsettled startup session_models event",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.String("current_model_id", payload.Data.CurrentModelID))
|
||||
return
|
||||
}
|
||||
|
||||
// Store the write-once baseline before the mutable selector snapshot so a
|
||||
// concurrent task-detail boot cannot observe the new state without its
|
||||
@@ -3094,7 +3111,6 @@ func (s *Service) handleSessionModelsEvent(ctx context.Context, payload *lifecyc
|
||||
zap.Error(err))
|
||||
return
|
||||
}
|
||||
settled := configOptionsSettled(payload.Data.Data)
|
||||
s.persistSessionModelAndRuntimeConfigWithSettlement(
|
||||
ctx, sessionID, payload.Data.CurrentModelID, "", payload.Data.SessionModels, payload.Data.ConfigOptions, settled,
|
||||
)
|
||||
@@ -3550,6 +3566,10 @@ func configOptionsSettled(data any) bool {
|
||||
return result
|
||||
}
|
||||
|
||||
func shouldDeferUnsettledStartupModelsEvent(session *models.TaskSession, settled bool) bool {
|
||||
return !settled && session != nil && session.State == models.TaskSessionStateStarting
|
||||
}
|
||||
|
||||
func originalConfigSettled(data any) bool {
|
||||
metadata, _ := data.(map[string]any)
|
||||
result, _ := metadata["original_config_settled"].(bool)
|
||||
|
||||
@@ -3417,6 +3417,100 @@ func TestPersistSessionRuntimeConfigUpdatesProviderState(t *testing.T) {
|
||||
require.Equal(t, map[string]string{"model": "mock-fast", "effort": "medium"}, cfg.ConfigOptions)
|
||||
}
|
||||
|
||||
func TestHandleSessionModelsEventDefersUnsettledStartupState(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := setupTestRepo(t)
|
||||
seedSession(t, repo, "t1", "s1", "step1")
|
||||
require.NoError(t, repo.UpdateTaskSessionState(ctx, "s1", models.TaskSessionStateStarting, ""))
|
||||
require.NoError(t, repo.SetSessionMetadataKey(ctx, "s1", models.SessionMetaKeyOrigin, models.SessionOriginTaskInitial))
|
||||
require.NoError(t, repo.SetSessionMetadataKey(ctx, "s1", models.SessionMetaKeyRuntimeConfig, models.SessionRuntimeConfig{
|
||||
Model: "gpt-5.6-sol",
|
||||
ConfigOptions: map[string]string{
|
||||
"model": "gpt-5.6-sol",
|
||||
"reasoning_effort": "high",
|
||||
},
|
||||
}))
|
||||
require.NoError(t, repo.SetSessionMetadataKey(ctx, "s1", models.SessionMetaKeyACPConfigBaseline, map[string]string{
|
||||
"model": "gpt-5.6-sol",
|
||||
"reasoning_effort": "medium",
|
||||
}))
|
||||
require.NoError(t, repo.SetSessionMetadataKey(ctx, "s1", models.SessionMetaKeyACPModelState, lifecycle.SessionModelsSnapshot{
|
||||
CurrentModelID: "gpt-5.6-sol",
|
||||
Models: []streams.SessionModelInfo{{ModelID: "gpt-5.6-sol", Name: "GPT-5.6 Sol"}},
|
||||
ConfigOptions: []streams.ConfigOption{{
|
||||
ID: "model", Category: "model", CurrentValue: "gpt-5.6-sol",
|
||||
}},
|
||||
ConfigOptionsSettled: true,
|
||||
}))
|
||||
session, err := repo.GetTaskSession(ctx, "s1")
|
||||
require.NoError(t, err)
|
||||
session.AgentProfileSnapshot = map[string]interface{}{"model": "gpt-5.6-sol"}
|
||||
require.NoError(t, repo.UpdateTaskSession(ctx, session))
|
||||
|
||||
eb := &recordingEventBus{}
|
||||
svc := &Service{logger: testLogger(), repo: repo, eventBus: eb}
|
||||
unsettled := func(model string) {
|
||||
svc.handleSessionModelsEvent(ctx, &lifecycle.AgentStreamEventPayload{
|
||||
TaskID: "t1",
|
||||
SessionID: "s1",
|
||||
AgentID: "a1",
|
||||
Data: &lifecycle.AgentStreamEventData{
|
||||
CurrentModelID: model,
|
||||
SessionModels: []streams.SessionModelInfo{{ModelID: model, Name: model}},
|
||||
OriginalConfigCandidate: []streams.ConfigOption{{
|
||||
Type: "select", ID: "reasoning_effort", CurrentValue: "high",
|
||||
}},
|
||||
ConfigOptions: []streams.ConfigOption{
|
||||
{ID: "model", Category: "model", CurrentValue: model},
|
||||
{ID: "reasoning_effort", CurrentValue: "low"},
|
||||
},
|
||||
Data: map[string]any{"original_config_settled": true},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
unsettled("gpt-5.6-luna")
|
||||
|
||||
updated, err := repo.GetTaskSession(ctx, "s1")
|
||||
require.NoError(t, err)
|
||||
runtimeConfig, ok := models.LoadSessionRuntimeConfig(updated.Metadata)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "gpt-5.6-sol", runtimeConfig.Model)
|
||||
require.Equal(t, "gpt-5.6-sol", updated.AgentProfileSnapshot["model"])
|
||||
modelState, ok := lifecycle.LoadSessionModelsSnapshot(updated.Metadata[models.SessionMetaKeyACPModelState])
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "gpt-5.6-sol", modelState.CurrentModelID)
|
||||
require.Empty(t, eb.events)
|
||||
|
||||
svc.handleSessionModelsEvent(ctx, &lifecycle.AgentStreamEventPayload{
|
||||
TaskID: "t1",
|
||||
SessionID: "s1",
|
||||
AgentID: "a1",
|
||||
Data: &lifecycle.AgentStreamEventData{
|
||||
CurrentModelID: "gpt-5.6-sol",
|
||||
SessionModels: []streams.SessionModelInfo{{ModelID: "gpt-5.6-sol", Name: "GPT-5.6 Sol"}},
|
||||
ConfigOptions: []streams.ConfigOption{
|
||||
{ID: "model", Category: "model", CurrentValue: "gpt-5.6-sol"},
|
||||
{ID: "reasoning_effort", CurrentValue: "high"},
|
||||
},
|
||||
Data: map[string]any{
|
||||
"config_options_settled": true,
|
||||
"original_config_settled": true,
|
||||
},
|
||||
},
|
||||
})
|
||||
require.Len(t, eb.events, 1)
|
||||
|
||||
require.NoError(t, repo.UpdateTaskSessionState(ctx, "s1", models.TaskSessionStateRunning, ""))
|
||||
unsettled("gpt-5.6-luna")
|
||||
require.Len(t, eb.events, 2)
|
||||
updated, err = repo.GetTaskSession(ctx, "s1")
|
||||
require.NoError(t, err)
|
||||
runtimeConfig, ok = models.LoadSessionRuntimeConfig(updated.Metadata)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "gpt-5.6-luna", runtimeConfig.Model)
|
||||
}
|
||||
|
||||
func TestHandleSessionModelsEventPublishesPersistedConfigBaselineAfterRestart(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := setupTestRepo(t)
|
||||
|
||||
@@ -52,17 +52,17 @@ describe("resolveSessionTabTitle", () => {
|
||||
).toBe(SPARK_MODEL_NAME);
|
||||
});
|
||||
|
||||
it("uses the live model config value when the generic current model is stale", () => {
|
||||
it("prefers the authoritative current model over a contradictory model option", () => {
|
||||
expect(
|
||||
resolveSessionTabTitle({
|
||||
...baseArgs,
|
||||
currentModelId: DEFAULT_MODEL_ID,
|
||||
currentModelId: SPARK_MODEL_ID,
|
||||
configOptions: [
|
||||
{
|
||||
type: "select",
|
||||
id: "model",
|
||||
name: "Model",
|
||||
currentValue: SPARK_MODEL_ID,
|
||||
currentValue: DEFAULT_MODEL_ID,
|
||||
options: [
|
||||
{ value: DEFAULT_MODEL_ID, name: "Mock Default" },
|
||||
{ value: SPARK_MODEL_ID, name: SPARK_MODEL_NAME },
|
||||
|
||||
@@ -39,7 +39,7 @@ function resolveModelTitle(
|
||||
export function resolveSessionTabTitle(args: ResolveSessionTabTitleArgs): string | null {
|
||||
if (args.customName) return args.customName;
|
||||
const modelConfig = args.configOptions.find(isModelConfigOption);
|
||||
const currentModelId = modelConfig?.currentValue || args.currentModelId;
|
||||
const currentModelId = args.currentModelId || modelConfig?.currentValue || null;
|
||||
return (
|
||||
resolveModelTitle(args, currentModelId) ??
|
||||
resolveModelTitle(args, args.activeModelId) ??
|
||||
|
||||
@@ -38,6 +38,45 @@ async function openTaskSession(page: Page, title: string): Promise<SessionPage>
|
||||
return session;
|
||||
}
|
||||
|
||||
type SessionTabHistoryEntry = { id: string; text: string };
|
||||
|
||||
async function recordSessionTabHistory(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
const history: SessionTabHistoryEntry[][] = [];
|
||||
const record = () => {
|
||||
const entries = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[data-testid^="session-tab-"]'),
|
||||
)
|
||||
.map((element) => {
|
||||
const testId = element.getAttribute("data-testid") ?? "";
|
||||
return {
|
||||
id: testId.slice("session-tab-".length),
|
||||
text: element.textContent?.trim() ?? "",
|
||||
};
|
||||
})
|
||||
.filter((entry) => entry.id && entry.text);
|
||||
if (entries.length > 0) history.push(entries);
|
||||
};
|
||||
const observer = new MutationObserver(record);
|
||||
const start = () => {
|
||||
observer.observe(document.documentElement, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
characterData: true,
|
||||
});
|
||||
record();
|
||||
};
|
||||
if (document.documentElement) {
|
||||
start();
|
||||
} else {
|
||||
document.addEventListener("DOMContentLoaded", start, { once: true });
|
||||
}
|
||||
(
|
||||
window as Window & { __KANDEV_SESSION_TAB_HISTORY__?: SessionTabHistoryEntry[][] }
|
||||
).__KANDEV_SESSION_TAB_HISTORY__ = history;
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests — ACP (normal) mode
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -440,6 +479,121 @@ test.describe("Session resume (multi-session)", () => {
|
||||
)
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
test("keeps distinct model titles during multi-session resume", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
backend,
|
||||
prCapture,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
|
||||
const { agents } = await apiClient.listAgents();
|
||||
const mockAgent = agents.find((agent) => agent.name === "mock-agent") ?? agents[0];
|
||||
if (!mockAgent) throw new Error("mock-agent is not available for model resume test");
|
||||
const solProfile = await apiClient.createAgentProfile(mockAgent.id, "Resume Sol Profile", {
|
||||
model: "mock-smart",
|
||||
});
|
||||
const lunaProfile = await apiClient.createAgentProfile(mockAgent.id, "Resume Luna Profile", {
|
||||
model: "mock-fast",
|
||||
});
|
||||
|
||||
const task = await apiClient.createTaskWithAgent(
|
||||
seedData.workspaceId,
|
||||
"Distinct Model Resume Task",
|
||||
solProfile.id,
|
||||
{
|
||||
description: "/e2e:simple-message",
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
},
|
||||
);
|
||||
const firstSessionId = task.session_id;
|
||||
if (!firstSessionId) throw new Error("first model resume session was not created");
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { sessions } = await apiClient.listTaskSessions(task.id);
|
||||
return sessions.some(
|
||||
(session) =>
|
||||
session.id === firstSessionId &&
|
||||
["WAITING_FOR_INPUT", "COMPLETED"].includes(session.state),
|
||||
);
|
||||
},
|
||||
{ timeout: 60_000, message: "first distinct-model session did not settle" },
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
const second = await apiClient.launchSession(
|
||||
{
|
||||
task_id: task.id,
|
||||
agent_profile_id: lunaProfile.id,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
prompt: "/e2e:simple-message",
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
const secondSessionId = second.session_id;
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { sessions } = await apiClient.listTaskSessions(task.id);
|
||||
return (
|
||||
sessions.length === 2 &&
|
||||
sessions.every((session) => ["WAITING_FOR_INPUT", "COMPLETED"].includes(session.state))
|
||||
);
|
||||
},
|
||||
{ timeout: 60_000, message: "both distinct-model sessions did not settle" },
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
await testPage.goto(`/t/${task.id}`);
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForLoad();
|
||||
await expect(session.sessionTabBySessionId(firstSessionId)).toContainText("Mock Smart", {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(session.sessionTabBySessionId(secondSessionId)).toContainText("Mock Fast", {
|
||||
timeout: 15_000,
|
||||
});
|
||||
await prCapture.screenshot("desktop-distinct-model-tabs", {
|
||||
caption: "Distinct model labels remain visible across desktop session tabs",
|
||||
});
|
||||
|
||||
await recordSessionTabHistory(testPage);
|
||||
await backend.restart();
|
||||
await testPage.reload();
|
||||
await session.waitForLoad();
|
||||
|
||||
await expect(session.sessionTabBySessionId(firstSessionId)).toContainText("Mock Smart", {
|
||||
timeout: 60_000,
|
||||
});
|
||||
await expect(session.sessionTabBySessionId(secondSessionId)).toContainText("Mock Fast", {
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
const history = await testPage.evaluate(
|
||||
() =>
|
||||
(window as Window & { __KANDEV_SESSION_TAB_HISTORY__?: SessionTabHistoryEntry[][] })
|
||||
.__KANDEV_SESSION_TAB_HISTORY__ ?? [],
|
||||
);
|
||||
const completeSamples = history.filter((sample) => {
|
||||
const first = sample.find((entry) => entry.id === firstSessionId);
|
||||
const secondEntry = sample.find((entry) => entry.id === secondSessionId);
|
||||
return !!first?.text && !!secondEntry?.text;
|
||||
});
|
||||
expect(completeSamples.length).toBeGreaterThan(0);
|
||||
for (const sample of completeSamples) {
|
||||
const first = sample.find((entry) => entry.id === firstSessionId);
|
||||
const secondEntry = sample.find((entry) => entry.id === secondSessionId);
|
||||
expect(first?.text).toContain("Mock Smart");
|
||||
expect(secondEntry?.text).toContain("Mock Fast");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import { expect, it, vi } from "vitest";
|
||||
import type { StoreApi } from "zustand";
|
||||
import type { AppState } from "@/lib/state/store";
|
||||
import type { BackendMessageMap } from "@/lib/types/backend";
|
||||
import type { TaskSession } from "@/lib/types/http";
|
||||
import type { SessionModelsPayload } from "@/lib/types/session-runtime-payloads";
|
||||
import { registerSessionModelsHandlers } from "./session-models";
|
||||
|
||||
const providerModelId = "gpt-5.6-sol";
|
||||
const providerModelName = "GPT-5.6 Sol";
|
||||
const lunaModelId = "gpt-5.6-luna";
|
||||
const reasoningOptionId = "reasoning_effort";
|
||||
const reasoningOptionName = "Reasoning Effort";
|
||||
|
||||
function makeStore(overrides: Partial<AppState> = {}): StoreApi<AppState> {
|
||||
const state = {
|
||||
contextWindow: { bySessionId: {} },
|
||||
sessionModels: { bySessionId: {} },
|
||||
taskSessions: { items: {} },
|
||||
setActiveModel: vi.fn(),
|
||||
...overrides,
|
||||
} as unknown as AppState;
|
||||
state.setSessionModels = vi.fn((sessionId, data) => {
|
||||
state.sessionModels.bySessionId[sessionId] = data;
|
||||
});
|
||||
state.clearContextWindow = vi.fn((sessionId) => {
|
||||
delete state.contextWindow.bySessionId[sessionId];
|
||||
});
|
||||
|
||||
return {
|
||||
getState: () => state,
|
||||
setState: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
getInitialState: vi.fn(),
|
||||
} as unknown as StoreApi<AppState>;
|
||||
}
|
||||
|
||||
function makePayload(
|
||||
currentModelId: string,
|
||||
overrides: Partial<SessionModelsPayload> = {},
|
||||
): SessionModelsPayload {
|
||||
return {
|
||||
task_id: "task-1",
|
||||
session_id: "session-1",
|
||||
agent_id: "agent-1",
|
||||
current_model_id: currentModelId,
|
||||
models: [],
|
||||
config_options: [],
|
||||
timestamp: "2026-06-11T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeMessage(payload: SessionModelsPayload): BackendMessageMap["session.models_updated"] {
|
||||
return {
|
||||
id: "message-1",
|
||||
type: "notification",
|
||||
action: "session.models_updated",
|
||||
payload,
|
||||
};
|
||||
}
|
||||
|
||||
function makeTaskSession(metadata: Record<string, unknown>): TaskSession {
|
||||
return {
|
||||
id: "session-1",
|
||||
task_id: "task-1",
|
||||
state: "WAITING_FOR_INPUT",
|
||||
metadata,
|
||||
started_at: "2026-06-11T00:00:00.000Z",
|
||||
updated_at: "2026-06-11T00:00:00.000Z",
|
||||
} as TaskSession;
|
||||
}
|
||||
|
||||
function makeStartupPayload(
|
||||
currentModelId: string,
|
||||
configModelId: string,
|
||||
settled: boolean,
|
||||
effort: string,
|
||||
): SessionModelsPayload {
|
||||
return makePayload(currentModelId, {
|
||||
config_options_settled: settled,
|
||||
config_options: [
|
||||
{
|
||||
type: "select",
|
||||
id: "model",
|
||||
name: "Model",
|
||||
current_value: configModelId,
|
||||
options: [
|
||||
{ value: providerModelId, name: providerModelName },
|
||||
{ value: lunaModelId, name: "GPT-5.6 Luna" },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "select",
|
||||
id: reasoningOptionId,
|
||||
name: reasoningOptionName,
|
||||
current_value: effort,
|
||||
options: [
|
||||
{ value: "high", name: "High" },
|
||||
{ value: "low", name: "Low" },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function expectCurrentModel(store: StoreApi<AppState>, modelId: string): void {
|
||||
expect(store.getState().sessionModels.bySessionId["session-1"]).toMatchObject({
|
||||
currentModelId: modelId,
|
||||
configOptions: expect.arrayContaining([
|
||||
expect.objectContaining({ id: "model", currentValue: modelId }),
|
||||
]),
|
||||
});
|
||||
}
|
||||
|
||||
function expectPersistedModel(store: StoreApi<AppState>): void {
|
||||
expectCurrentModel(store, providerModelId);
|
||||
}
|
||||
|
||||
it("keeps persisted model state until a settled startup payload arrives", () => {
|
||||
const store = makeStore({
|
||||
taskSessions: {
|
||||
items: {
|
||||
"session-1": {
|
||||
...makeTaskSession({ runtime_config: { model: providerModelId } }),
|
||||
state: "STARTING",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const handler = registerSessionModelsHandlers(store)["session.models_updated"]!;
|
||||
|
||||
handler(makeMessage(makeStartupPayload(providerModelId, lunaModelId, false, "low")));
|
||||
expectPersistedModel(store);
|
||||
|
||||
handler(makeMessage(makeStartupPayload(lunaModelId, lunaModelId, false, "low")));
|
||||
expectPersistedModel(store);
|
||||
|
||||
handler(makeMessage(makeStartupPayload(providerModelId, providerModelId, true, "high")));
|
||||
expectPersistedModel(store);
|
||||
expect(store.getState().sessionModels.bySessionId["session-1"]).toMatchObject({
|
||||
configOptionsSettled: true,
|
||||
});
|
||||
|
||||
store.getState().taskSessions.items["session-1"].state = "WAITING_FOR_INPUT";
|
||||
handler(makeMessage(makeStartupPayload(lunaModelId, lunaModelId, false, "low")));
|
||||
const liveState = store.getState().sessionModels.bySessionId["session-1"];
|
||||
expect(liveState.currentModelId).toBe(lunaModelId);
|
||||
expect(liveState.configOptions.find((option) => option.id === "model")?.currentValue).toBe(
|
||||
lunaModelId,
|
||||
);
|
||||
});
|
||||
|
||||
it("restores persisted runtime after a hydrated session restarts", () => {
|
||||
const store = makeStore({
|
||||
contextWindow: {
|
||||
bySessionId: {
|
||||
"session-1": {
|
||||
size: 100,
|
||||
used: 40,
|
||||
remaining: 60,
|
||||
efficiency: 60,
|
||||
compactionCount: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
taskSessions: {
|
||||
items: {
|
||||
"session-1": makeTaskSession({ runtime_config: { model: providerModelId } }),
|
||||
},
|
||||
},
|
||||
});
|
||||
const handler = registerSessionModelsHandlers(store)["session.models_updated"]!;
|
||||
|
||||
handler(makeMessage(makeStartupPayload(providerModelId, providerModelId, true, "high")));
|
||||
expectPersistedModel(store);
|
||||
|
||||
store.getState().taskSessions.items["session-1"].state = "STARTING";
|
||||
handler(makeMessage(makeStartupPayload(lunaModelId, lunaModelId, false, "low")));
|
||||
expectPersistedModel(store);
|
||||
expect(store.getState().clearContextWindow).not.toHaveBeenCalled();
|
||||
expect(store.getState().contextWindow.bySessionId["session-1"]).toBeDefined();
|
||||
|
||||
handler(makeMessage(makeStartupPayload(providerModelId, providerModelId, true, "high")));
|
||||
expectPersistedModel(store);
|
||||
});
|
||||
|
||||
it("hydrates from a settled startup payload when the model differs from persisted runtime", () => {
|
||||
const store = makeStore({
|
||||
taskSessions: {
|
||||
items: {
|
||||
"session-1": {
|
||||
...makeTaskSession({ runtime_config: { model: providerModelId } }),
|
||||
state: "STARTING",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const handler = registerSessionModelsHandlers(store)["session.models_updated"]!;
|
||||
|
||||
handler(makeMessage(makeStartupPayload(lunaModelId, lunaModelId, true, "low")));
|
||||
expectCurrentModel(store, lunaModelId);
|
||||
expect(store.getState().sessionModels.bySessionId["session-1"]).toMatchObject({
|
||||
configOptionsSettled: true,
|
||||
});
|
||||
|
||||
store.getState().taskSessions.items["session-1"].state = "WAITING_FOR_INPUT";
|
||||
handler(makeMessage(makeStartupPayload(providerModelId, providerModelId, false, "high")));
|
||||
expectCurrentModel(store, providerModelId);
|
||||
});
|
||||
@@ -83,10 +83,37 @@ function resolveCurrentModelId(payload: SessionModelsPayload): string {
|
||||
return modelOpt?.current_value ?? "";
|
||||
}
|
||||
|
||||
function isModelConfigOption(option: SessionModelConfigOption): boolean {
|
||||
function isModelConfigOption(option: Pick<SessionModelConfigOption, "id" | "category">): boolean {
|
||||
return option.id === "model" || option.category === "model";
|
||||
}
|
||||
|
||||
function isUnsettledStartupModelsPayload(
|
||||
state: AppState,
|
||||
sessionId: string,
|
||||
payload: SessionModelsPayload,
|
||||
): boolean {
|
||||
return (
|
||||
state.taskSessions.items[sessionId]?.state === "STARTING" &&
|
||||
payload.config_options_settled !== true
|
||||
);
|
||||
}
|
||||
|
||||
function shouldHydrateSessionModelsPayload(
|
||||
payload: SessionModelsPayload,
|
||||
matchesPersisted: boolean,
|
||||
unsettledStartup: boolean,
|
||||
): boolean {
|
||||
// Two-layer defense: the backend gates unsettled startup events, while this
|
||||
// barrier protects reconnects where the client session state lags.
|
||||
return payload.config_options_settled === true || (matchesPersisted && !unsettledStartup);
|
||||
}
|
||||
|
||||
function shouldUsePersistedRuntimeConfig(hydrated: boolean, unsettledStartup: boolean): boolean {
|
||||
// A store can outlive the backend session after a restart. Keep persisted
|
||||
// runtime values authoritative until the resumed startup payload settles.
|
||||
return !hydrated || unsettledStartup;
|
||||
}
|
||||
|
||||
// During an agentctl relaunch (ready -> starting -> ready) the backend can emit
|
||||
// a transient session.models_updated with no models, no current model, and no
|
||||
// config options before the fresh agent reports its real capabilities. Since
|
||||
@@ -164,16 +191,23 @@ function resolveConfigOptions(
|
||||
existing: ConfigOptionEntry[] | undefined,
|
||||
payload: SessionModelsPayload,
|
||||
pendingRuntime: PersistedRuntimeConfig,
|
||||
currentModelId: string,
|
||||
): ConfigOptionEntry[] {
|
||||
if (preserve && existing) {
|
||||
return existing;
|
||||
return existing.map((option) =>
|
||||
isModelConfigOption(option) && currentModelId
|
||||
? { ...option, currentValue: currentModelId }
|
||||
: option,
|
||||
);
|
||||
}
|
||||
return (payload.config_options ?? []).map((o) => ({
|
||||
type: o.type,
|
||||
id: o.id,
|
||||
name: o.name,
|
||||
description: o.description,
|
||||
currentValue: pendingRuntime.configOptions?.[o.id] ?? o.current_value,
|
||||
currentValue: isModelConfigOption(o)
|
||||
? currentModelId || pendingRuntime.configOptions?.[o.id] || o.current_value
|
||||
: (pendingRuntime.configOptions?.[o.id] ?? o.current_value),
|
||||
category: o.category,
|
||||
options: o.options,
|
||||
}));
|
||||
@@ -271,10 +305,22 @@ export function registerSessionModelsHandlers(store: StoreApi<AppState>): WsHand
|
||||
const sessionId = payload.session_id;
|
||||
const state = store.getState();
|
||||
const hydrated = hydratedSessions(store);
|
||||
const persisted = hydrated.has(sessionId) ? {} : persistedRuntimeConfig(state, sessionId);
|
||||
const unsettledStartup = isUnsettledStartupModelsPayload(state, sessionId, payload);
|
||||
const usePersistedRuntime = shouldUsePersistedRuntimeConfig(
|
||||
hydrated.has(sessionId),
|
||||
unsettledStartup,
|
||||
);
|
||||
const persisted = usePersistedRuntime ? persistedRuntimeConfig(state, sessionId) : {};
|
||||
const matchesPersisted = payloadMatchesPersistedRuntime(payload, persisted);
|
||||
if (matchesPersisted) hydrated.add(sessionId);
|
||||
const pendingRuntime = matchesPersisted ? {} : persisted;
|
||||
if (shouldHydrateSessionModelsPayload(payload, matchesPersisted, unsettledStartup)) {
|
||||
hydrated.add(sessionId);
|
||||
}
|
||||
const pendingRuntime = shouldUsePersistedRuntimeConfig(
|
||||
hydrated.has(sessionId),
|
||||
unsettledStartup,
|
||||
)
|
||||
? persisted
|
||||
: {};
|
||||
const resolved = resolveModelsUpdatedState(state, sessionId, payload, pendingRuntime);
|
||||
debugModelsUpdate(state, sessionId, payload, resolved);
|
||||
if (resolved.isEmpty && resolved.populated) {
|
||||
@@ -287,6 +333,7 @@ export function registerSessionModelsHandlers(store: StoreApi<AppState>): WsHand
|
||||
resolved.existingEntry?.configOptions,
|
||||
payload,
|
||||
pendingRuntime,
|
||||
resolved.currentModelId,
|
||||
);
|
||||
const configOptionsSettled = resolvedConfigOptionsSettled(payload, resolved.existingEntry);
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
---
|
||||
spec: docs/specs/ui/acp-model-configuration-summary.md
|
||||
created: 2026-08-21
|
||||
status: implemented
|
||||
---
|
||||
|
||||
# Implementation Plan: Resumed Session Model Labels
|
||||
|
||||
## Overview
|
||||
|
||||
The backend currently persists and publishes every ACP `session_models` event during resume. Codex first reports its provider default, then Kandev applies the saved profile model.
|
||||
|
||||
The frontend can also trust that first event before startup configuration settles. The tab resolver then prefers its model option over the session current-model field.
|
||||
|
||||
Gate mutable startup events in the backend first. Then keep the frontend hydration barrier active through settlement and make all model labels use one current-model value.
|
||||
|
||||
## Confirmed Root Cause
|
||||
|
||||
The Sol session resumed with profile `077cca54-38eb-4dc3-b859-846fe8e90280`. ACP first reported `gpt-5.6-luna`, then Kandev applied `gpt-5.6-sol` before the execution became ready.
|
||||
|
||||
`handleSessionModelsEvent` persisted and published the intermediate Luna event. The browser accepted the event before `config_options_settled` and let the tab-title model option override `currentModelId`.
|
||||
|
||||
The process ran Sol before it accepted input. The fault was a transient durable and visual model regression, not an execution-profile mix-up.
|
||||
|
||||
## Backend
|
||||
|
||||
### Startup event gate
|
||||
|
||||
- Update `apps/backend/internal/orchestrator/event_handlers_streaming.go` in `handleSessionModelsEvent`.
|
||||
- Continue to persist an `original_config_settled` candidate before the gate.
|
||||
- Read the task-session state after the original-configuration path.
|
||||
- During `STARTING`, do not persist or publish an event unless `config_options_settled` is true.
|
||||
- Continue to accept unsettled live updates after startup, so user model changes still converge.
|
||||
- Add a small predicate such as `shouldDeferUnsettledStartupModelsEvent` to keep the state rule testable.
|
||||
|
||||
No WebSocket payload or database schema changes are required.
|
||||
|
||||
## Frontend
|
||||
|
||||
### Runtime hydration barrier
|
||||
|
||||
- Update `apps/web/lib/ws/handlers/session-models.ts`.
|
||||
- Keep persisted runtime values active while a session is `STARTING` and the payload is unsettled.
|
||||
- Mark the session hydrated only after a matching settled startup payload arrives.
|
||||
- Normalize the model config option to the resolved `currentModelId` before the store write.
|
||||
- Continue to trust later live provider updates after startup settlement.
|
||||
|
||||
### Session tab title
|
||||
|
||||
- Update `apps/web/components/task/session-tab-title.ts`.
|
||||
- Prefer `currentModelId` when it is available.
|
||||
- Use the model config option only when the current-model field is absent.
|
||||
- Preserve custom-name, active-model, agent-label, and snapshot fallbacks.
|
||||
|
||||
### Mobile parity
|
||||
|
||||
The dock tab exists only in `DockviewDesktopLayout`. Phone and tablet task layouts use the shared session runtime store and their existing model selector.
|
||||
|
||||
This repair changes state normalization, not composition, navigation, touch behavior, scroll ownership, or safe-area behavior. The nearest mobile exemplar remains `apps/web/e2e/tests/chat/mobile-model-selector.spec.ts`.
|
||||
|
||||
No new mobile Playwright case is required. The shared handler unit test and the existing mobile selector test cover the affected mobile state path.
|
||||
|
||||
## Tests
|
||||
|
||||
- **What:** An unsettled startup default cannot replace a saved model, runtime configuration, or selector snapshot.
|
||||
**File:** `apps/backend/internal/orchestrator/event_handlers_streaming_test.go`.
|
||||
**How:** Set a seeded session to `STARTING`, persist Sol, send an unsettled Luna event, and assert no mutable write or WebSocket publication. Then send settled Sol and assert one publication.
|
||||
- **What:** An unsettled `Sol -> Luna -> Sol` startup sequence keeps Sol until settlement.
|
||||
**File:** `apps/web/lib/ws/handlers/session-models-startup.test.ts`.
|
||||
**How:** Use persisted Sol metadata and a `STARTING` session. Assert that both `currentModelId` and the model option remain Sol until the settled Sol payload.
|
||||
- **What:** A contradictory model option cannot override the explicit current model in a tab title.
|
||||
**File:** `apps/web/components/task/session-tab-title.test.ts`.
|
||||
**How:** Give the resolver Sol as `currentModelId` and Luna as the model option. Assert the title is Sol. Keep the option-only fallback case.
|
||||
- **What:** Live model changes still work after startup.
|
||||
**Files:** `apps/backend/internal/orchestrator/event_handlers_streaming_test.go` and `apps/web/lib/ws/handlers/session-models.test.ts`.
|
||||
**How:** Send an unsettled update in a non-`STARTING` state or after hydration. Assert that the new model is accepted.
|
||||
|
||||
## E2E Tests
|
||||
|
||||
- **Scenario:** GIVEN a task has two sessions with different models, WHEN the backend resumes both, THEN each desktop tab keeps its session model.
|
||||
**File:** `apps/web/e2e/tests/session/session-resume.spec.ts`.
|
||||
**What to verify:** Create two mock profiles with distinct models, launch one session per profile, and capture both session-tab texts across reload. No observation can show the same default model on both tabs.
|
||||
- **Mobile coverage:** Run the existing `apps/web/e2e/tests/chat/mobile-model-selector.spec.ts` focused model-selection case. It proves that the shared state path still supports the phone selector.
|
||||
|
||||
## Verification Results
|
||||
|
||||
- Backend focused startup regression: `cd apps/backend && go test -run 'TestHandleSessionModelsEvent.*Startup' ./internal/orchestrator` passed.
|
||||
- Full backend suite: `make test` from `apps/backend` passed with `CGO_ENABLED=1 go test -tags fts5 ./...`.
|
||||
- Frontend focused unit suite: `cd apps && pnpm --filter @kandev/web test -- --run lib/ws/handlers/session-models.test.ts lib/ws/handlers/session-models-startup.test.ts components/task/session-tab-title.test.ts` passed with 3 files and 30 tests.
|
||||
- Frontend typecheck: `cd apps/web && pnpm run typecheck` passed.
|
||||
- Desktop resume E2E: `cd apps/web && pnpm e2e:run tests/session/session-resume.spec.ts -- --grep "keeps distinct model titles during multi-session resume"` passed with 1 test.
|
||||
- Mobile selector E2E: `cd apps/web && pnpm e2e:run --project mobile-chrome tests/chat/mobile-model-selector.spec.ts -- --grep "model"` passed with 1 test.
|
||||
- PR evidence capture passed for both desktop session tabs and the existing mobile selector; the manifest maps both fresh PNGs and both images were inspected and compressed.
|
||||
- PR fixup remediation: a settled startup payload now releases hydration even when it differs from persisted runtime data; the added regression passed, backend focused tests passed, web lint passed with zero warnings, typecheck passed, and the production desktop resume E2E passed.
|
||||
- PR review follow-up: persisted runtime configuration remains authoritative for an unsettled `STARTING` payload even when the store already marked the session hydrated after an earlier settled payload. The same-store restart regression passed, the affected frontend suite passed with 31 tests, changed-file ESLint passed with zero warnings, typecheck passed, the production desktop resume E2E passed with 1 test, and the existing mobile model-selector E2E passed with 1 test. The restart history assertion now checks each session's expected model label rather than only comparing the two labels.
|
||||
- Rebase/fixup verification: rebased cleanly onto `origin/main` at `032ea05b`, which includes the fix for the former `service_pr_watch.go` compile failure. `go build ./...`, `go test -race ./...` (10,069 tests in 136 packages), the focused GitHub and orchestrator race suites, the affected frontend suite (31 tests), changed-file ESLint, typecheck, the production desktop resume E2E, and the existing mobile model-selector E2E all passed.
|
||||
|
||||
## Implementation Waves And Parallel Candidates
|
||||
|
||||
Wave 1:
|
||||
|
||||
- [x] [Task 01: Gate unsettled startup model events](task-01-gate-startup-model-events.md)
|
||||
|
||||
Wave 2:
|
||||
|
||||
- [x] [Task 02: Converge resumed session model labels](task-02-converge-session-model-labels.md)
|
||||
|
||||
Both tasks are sequential. Task 02 uses the backend gate from Task 01 in its restart E2E test.
|
||||
|
||||
## Open Questions
|
||||
|
||||
None.
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
id: "01-gate-startup-model-events"
|
||||
title: "Gate unsettled startup model events"
|
||||
status: done
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/ui/acp-model-configuration-summary.md"
|
||||
---
|
||||
|
||||
# Task 01: Gate Unsettled Startup Model Events
|
||||
|
||||
## Acceptance
|
||||
|
||||
- An unsettled `session_models` event during `STARTING` cannot replace durable runtime configuration or the boot selector snapshot.
|
||||
- The backend does not publish that intermediate event to the session WebSocket subject.
|
||||
- The settled startup event and later live model changes keep their current behavior.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps/backend && go test ./internal/orchestrator -run 'TestHandleSessionModelsEvent.*Startup'
|
||||
make -C apps/backend test
|
||||
```
|
||||
|
||||
## Files Likely Touched
|
||||
|
||||
- `apps/backend/internal/orchestrator/event_handlers_streaming.go`
|
||||
- `apps/backend/internal/orchestrator/event_handlers_streaming_test.go`
|
||||
- `docs/plans/resumed-session-model-labels/plan.md`
|
||||
- `docs/plans/resumed-session-model-labels/task-01-gate-startup-model-events.md`
|
||||
|
||||
## Dependencies
|
||||
|
||||
None.
|
||||
|
||||
## Parallelism
|
||||
|
||||
`sequential`. The frontend E2E depends on this startup event contract.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Spec requirements for persisted runtime state and restart convergence.
|
||||
- `handleSessionModelsEvent` and `configOptionsSettled` in `apps/backend/internal/orchestrator/event_handlers_streaming.go`.
|
||||
- `InitializeAndPromptWithLayers` startup order in `apps/backend/internal/agent/runtime/lifecycle/session.go`.
|
||||
- The observed `Luna -> Sol` resume sequence from session `83adf08e-d628-4b03-a711-7e43fd633358`.
|
||||
|
||||
## TDD Sequence
|
||||
|
||||
1. Add a test for an unsettled Luna event on a `STARTING` session that has persisted Sol state.
|
||||
2. Run the focused test and record the expected failure.
|
||||
3. Add the smallest startup-state gate after original-configuration capture.
|
||||
4. Add or retain a test that accepts an unsettled live update outside `STARTING`.
|
||||
5. Run the focused test and the backend test target.
|
||||
6. Update this task, the plan checkbox, and verification results.
|
||||
|
||||
## Risks
|
||||
|
||||
- The gate must not skip `original_config_settled` persistence.
|
||||
- The gate must not suppress user model changes after startup.
|
||||
- A settled empty configuration is valid and must pass the gate.
|
||||
|
||||
## Output Contract
|
||||
|
||||
Report the RED assertion, code change, files changed, exact test results, blockers, risks, and synchronized task and plan status.
|
||||
|
||||
## Results
|
||||
|
||||
- RED: `cd apps/backend && go test -run 'TestHandleSessionModelsEvent.*Startup' ./internal/orchestrator` failed because the unsettled startup event changed the persisted model from `gpt-5.6-sol` to `gpt-5.6-luna`.
|
||||
- GREEN: The same focused test passed after adding the `STARTING` plus unsettled gate.
|
||||
- Full backend check: `make test` from `apps/backend` passed (`CGO_ENABLED=1 go test -tags fts5 ./...`).
|
||||
- The test also confirms the original effective configuration candidate is captured before the gate and an unsettled live update is accepted after the session leaves `STARTING`.
|
||||
- Rebase/fixup verification: rebased cleanly onto `origin/main` at `032ea05b`, then `go build ./...` and `go test -race ./...` passed (`10,069` tests in `136` packages). The focused GitHub and orchestrator race suites also passed; the former CI compile failure is fixed by the current `main` base.
|
||||
@@ -0,0 +1,88 @@
|
||||
---
|
||||
id: "02-converge-session-model-labels"
|
||||
title: "Converge resumed session model labels"
|
||||
status: done
|
||||
wave: 2
|
||||
depends_on: ["01-gate-startup-model-events"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/ui/acp-model-configuration-summary.md"
|
||||
---
|
||||
|
||||
# Task 02: Converge Resumed Session Model Labels
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Persisted Sol state remains visible through an unsettled Luna startup event and converges on settled Sol.
|
||||
- Each unnamed tab and task selector show the same authoritative current model.
|
||||
- Later user model changes still update desktop and mobile selectors.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps && pnpm install --frozen-lockfile
|
||||
cd apps && pnpm --filter @kandev/web test -- --run lib/ws/handlers/session-models.test.ts lib/ws/handlers/session-models-startup.test.ts components/task/session-tab-title.test.ts
|
||||
cd apps/web && pnpm run typecheck
|
||||
cd apps/web && pnpm e2e:run tests/session/session-resume.spec.ts -- --grep "keeps distinct model titles during multi-session resume"
|
||||
cd apps/web && pnpm e2e:run --project mobile-chrome tests/chat/mobile-model-selector.spec.ts -- --grep "model"
|
||||
```
|
||||
|
||||
## Files Likely Touched
|
||||
|
||||
- `apps/web/lib/ws/handlers/session-models.ts`
|
||||
- `apps/web/lib/ws/handlers/session-models.test.ts`
|
||||
- `apps/web/lib/ws/handlers/session-models-startup.test.ts`
|
||||
- `apps/web/components/task/session-tab-title.ts`
|
||||
- `apps/web/components/task/session-tab-title.test.ts`
|
||||
- `apps/web/e2e/tests/session/session-resume.spec.ts`
|
||||
- `docs/specs/ui/acp-model-configuration-summary.md`
|
||||
- `docs/plans/resumed-session-model-labels/plan.md`
|
||||
- `docs/plans/resumed-session-model-labels/task-02-converge-session-model-labels.md`
|
||||
|
||||
## Dependencies
|
||||
|
||||
Task 01.
|
||||
|
||||
## Parallelism
|
||||
|
||||
`sequential`. The restart E2E proves the combined backend and frontend contract.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Spec scenarios for multi-session resume and live model changes.
|
||||
- The persisted runtime guard in `apps/web/lib/ws/handlers/session-models.ts`.
|
||||
- The title precedence in `apps/web/components/task/session-tab-title.ts`.
|
||||
- The existing multi-session restart fixture in `apps/web/e2e/tests/session/session-resume.spec.ts`.
|
||||
- The existing mobile selector fixture in `apps/web/e2e/tests/chat/mobile-model-selector.spec.ts`.
|
||||
|
||||
## TDD Sequence
|
||||
|
||||
1. Add the `Sol -> Luna -> Sol` handler test and record the expected failure.
|
||||
2. Add the contradictory title-state test and record the expected failure.
|
||||
3. Keep the persisted guard active until startup settlement and normalize the stored model option.
|
||||
4. Make the tab title prefer `currentModelId`, with an option-only fallback.
|
||||
5. Add the two-profile restart E2E and capture tab labels from first render through settlement.
|
||||
6. Run the focused unit tests, typecheck, desktop E2E, and existing mobile E2E.
|
||||
7. Update this task, the plan checkbox, the spec status, and verification results.
|
||||
|
||||
## Risks
|
||||
|
||||
- A permanent hydration barrier can block real post-start changes. The state and settlement tests must prove that the barrier releases.
|
||||
- The model config option and `currentModelId` must converge before selectors render.
|
||||
- Mutation-history E2E assertions must ignore missing tabs during layout restoration and inspect only mounted session tabs.
|
||||
|
||||
## Output Contract
|
||||
|
||||
Report both RED assertions, state and title changes, exact unit and E2E results, files changed, mobile evidence, blockers, risks, and synchronized artifact status.
|
||||
|
||||
## Results
|
||||
|
||||
- RED: The initial focused frontend run failed in the existing live-option precedence assertion and the new startup hydration assertion. The title test first observed the option-only model label, and the handler test observed the unsettled `gpt-5.6-luna` value instead of persisted `gpt-5.6-sol`.
|
||||
- GREEN: `cd apps && pnpm --filter @kandev/web test -- --run lib/ws/handlers/session-models.test.ts lib/ws/handlers/session-models-startup.test.ts components/task/session-tab-title.test.ts` passed with 3 files and 30 tests.
|
||||
- Typecheck: `cd apps/web && pnpm run typecheck` passed.
|
||||
- Desktop resume E2E: `cd apps/web && pnpm e2e:run tests/session/session-resume.spec.ts -- --grep "keeps distinct model titles during multi-session resume"` passed with 1 test in 9.3 seconds. Both distinct model tab labels remained visible after backend restart and reload.
|
||||
- Mobile parity E2E: `cd apps/web && pnpm e2e:run --project mobile-chrome tests/chat/mobile-model-selector.spec.ts -- --grep "model"` passed with 1 test in 11.6 seconds. The existing touch selector path remains usable; no new mobile composition or interaction was required.
|
||||
- PR evidence: capture runs produced fresh desktop and mobile PNGs, both manifest entries mapped to existing files, both images were inspected for seeded-only content, and `pngquant-bin` compressed them successfully.
|
||||
- PR fixup remediation: settled startup payloads now release the hydration barrier even when they differ from persisted runtime data; the new regression passed. The backend focused test, web lint with zero warnings, typecheck, and production desktop resume E2E also passed.
|
||||
- PR review follow-up: startup payload handling now reuses persisted runtime configuration whenever a `STARTING` session remains unsettled, even after the store has marked it hydrated. The same-store restart regression covers settled Sol, `STARTING`, unsettled Luna, preserved Sol context, and settled Sol. The focused startup suite passed with 3 tests, the affected frontend suite passed with 31 tests, changed-file ESLint passed with zero warnings, typecheck passed, the production desktop resume E2E passed with 1 test, and the existing mobile model-selector E2E passed with 1 test. Multi-session history now asserts `Mock Smart` and `Mock Fast` for their specific session IDs.
|
||||
- Rebase/fixup verification: the clean rebase onto `origin/main` at `032ea05b` removed the former CI build failure from the merged tree. The affected frontend suite passed with 31 tests, changed-file ESLint and typecheck passed, and the production desktop resume E2E plus the existing mobile model-selector E2E each passed with 1 test.
|
||||
- Files changed: `apps/web/lib/ws/handlers/session-models.ts`, `apps/web/lib/ws/handlers/session-models.test.ts`, `apps/web/lib/ws/handlers/session-models-startup.test.ts`, `apps/web/components/task/session-tab-title.ts`, `apps/web/components/task/session-tab-title.test.ts`, `apps/web/e2e/tests/session/session-resume.spec.ts`, and the synchronized spec/plan/task records.
|
||||
@@ -25,6 +25,9 @@ ACP agents can advertise an arbitrary ordered set of model-adjacent session conf
|
||||
- Before the reset, Kandev captures the effective model, permission mode, and every selected ACP configuration option.
|
||||
- After the reset, Kandev reapplies the captured model, mode, and options before it sends another prompt.
|
||||
- Fresh-session default events cannot replace the captured restoration intent. Provider convergence events remain the source of truth for accepted state, persistence, and UI hydration.
|
||||
- During process resume, each task session keeps its last persisted model until that session reports its settled startup configuration.
|
||||
- An unsettled provider-default event cannot replace durable runtime configuration or relabel an unnamed session tab.
|
||||
- A session tab and its task selector use one authoritative current model. Contradictory intermediate option data cannot give these surfaces different labels.
|
||||
- An unnamed agent tab derives its title from the task session's authoritative current model. Live config values may identify the current model, but non-model config values and the session mode are never appended to the tab title. The title updates when the model changes and remains correct after a task-detail refresh; a user-supplied session name continues to override the derived title.
|
||||
- Each turn stores an immutable configuration snapshot when the turn is created. The snapshot contains the effective model, mode, ordered selected config values with their provider-supplied display names, and the task-session provider-default baseline used for comparison. Agent-message metadata renders from this turn snapshot instead of the session's latest mutable runtime configuration.
|
||||
- Agent-message metadata always shows the attributed model and shows only non-model config values that differ from the turn's captured baseline. It keeps option names in the compact row because message attribution is read outside the selector context. Baseline-matching values and the session mode are omitted from the compact row; the mode remains available in turn metadata.
|
||||
@@ -50,6 +53,9 @@ Decision: [ADR-2026-08-18-context-reset-preserves-runtime-configuration](../../d
|
||||
- **GIVEN** a task session uses a non-default model, permission mode, and reasoning option, **WHEN** a workflow step resets context, **THEN** the fresh ACP session uses all three values before the next prompt.
|
||||
- **GIVEN** a fresh ACP session reports provider defaults during reset, **WHEN** restoration runs, **THEN** Kandev applies the configuration captured before reset instead of those defaults.
|
||||
- **GIVEN** a successful reset restores the complete runtime configuration, **WHEN** the task page reloads, **THEN** the model selector and permission selector show the restored values.
|
||||
- **GIVEN** a task has Luna and Sol sessions, **WHEN** restart resumes both and each agent first reports Luna, **THEN** each tab keeps its persisted model until settlement.
|
||||
- **GIVEN** a resumed session has a persisted model, **WHEN** an unsettled startup event reports another model, **THEN** the event does not replace durable runtime state or clear model-specific UI state.
|
||||
- **GIVEN** a resumed session has settled, **WHEN** the user changes its model, **THEN** the tab and task selector both show the new model.
|
||||
- **GIVEN** an unnamed agent tab whose selected model changes during the session, **WHEN** the model update converges or the task-detail page is refreshed, **THEN** the existing tab title shows only the current selected model instead of the agent profile's original model label or any session mode and non-model config values.
|
||||
- **GIVEN** turn A runs with reasoning `High`, **WHEN** reasoning changes to `Low` before turn B, **THEN** turn A continues to show `Reasoning effort: High` and turn B shows `Reasoning effort: Low`.
|
||||
- **GIVEN** a turn uses only provider-default options, **WHEN** its agent-message metadata renders, **THEN** the compact row shows only the attributed model.
|
||||
@@ -62,6 +68,7 @@ Decision: [ADR-2026-08-18-context-reset-preserves-runtime-configuration](../../d
|
||||
- Task-session metadata contains a dedicated write-once ACP provider-default baseline keyed by config option ID with raw selected values.
|
||||
- Task-session metadata also contains the latest complete ACP model selector state needed for task-detail boot hydration, including provider-supplied model and option metadata.
|
||||
- The provider's latest mutable state remains in runtime configuration metadata. Explicit user selections are stored separately and applied as overrides after that provider state, preventing delayed provider events from replacing resume intent. Baseline, live state, and explicit overrides have distinct ownership and lifecycle semantics.
|
||||
- During `STARTING`, unsettled provider events do not replace the last settled runtime configuration or selector snapshot. The settled startup event replaces both records.
|
||||
- Turn metadata contains a minimal immutable configuration snapshot. Selected option IDs and raw values support baseline comparison; captured option/value names and order preserve the provider's display semantics without depending on later session state.
|
||||
- ACP config option and option-value transport types carry optional descriptions.
|
||||
|
||||
@@ -78,6 +85,7 @@ Decision: [ADR-2026-08-18-context-reset-preserves-runtime-configuration](../../d
|
||||
- Once stored, the baseline is not replaced by later ACP updates, user selections, agent-initiated selections, backend restarts, or session resume.
|
||||
- The complete effective runtime configuration is captured before a context reset can emit fresh provider defaults.
|
||||
- A fresh default event cannot redefine the model, mode, or option values that Kandev restores.
|
||||
- A backend restart does not make an unsettled startup event durable when a prior settled model exists.
|
||||
- Baseline comparison is scoped to the task session, not the agent profile or provider globally.
|
||||
- Once a turn is created, its configuration snapshot is not changed by later session configuration events. Only provider-reported attribution fields such as the actual model and usage may be added to the turn.
|
||||
|
||||
@@ -88,3 +96,4 @@ Decision: [ADR-2026-08-18-context-reset-preserves-runtime-configuration](../../d
|
||||
- Reconstructing configuration snapshots for turns created before this behavior shipped.
|
||||
- Changing the closed-label behavior in agent-profile settings or other non-task selector surfaces.
|
||||
- Adding support for ACP input control types that Kandev does not otherwise render.
|
||||
- Changing the mobile task layout or adding a mobile dock-tab surface.
|
||||
|
||||
Reference in New Issue
Block a user