Fix session state and interaction ownership

Problem:
Session-scoped todos and interactive cards could briefly appear in another
session, and delayed callbacks could submit or clear the currently visible
request instead of the request that originated the action.

Root cause:
Runtime projections from independent sources were merged without a shared
producer version, while prompt and extension callbacks resolved mutable active
session state after the user action. Card-local state also used identifiers that
could be reused across request instances.

Fix:
Add generation-aware session bindings, versioned runtime projections, immutable
interaction targets, request-scoped drafts and component state, exact prompt
submission, and host-generated extension form instance identities. Gate exact
remote interactions with explicit capabilities and document the ownership
contracts.

Verification:
Passed frontend typechecks, lint and architecture gates, production build,
focused runtime/prompt/form/remote tests, browser lifecycle suites, affected Go
package tests, and race tests for extension UI and serve ownership. Broad root
and Desktop Go suites were attempted but exceeded the local execution window
without a related assertion failure.
This commit is contained in:
SivanCola committed 2026-09-18 16:42:58 +08:00
1 parent 9b0cc56e5c
commit 52eb62bd1e
75 files changed
+2571 -552

No files matched your search

+120 -86
View File
@@ -6421,96 +6421,130 @@ func (a *App) loadConfigForVision(root string) (*config.Config, error) {
}
func (a *App) MetaForTab(tabID string) Meta {
a.mu.RLock()
tab := a.tabByIDLocked(tabID)
snap := snapshotTabRuntimeLocked(tab)
runtimeView := a.sessionRuntimeViewLocked(tab)
a.mu.RUnlock()
if tab == nil {
meta := Meta{EventChannel: eventChannel}
if ref, ok := a.remoteTabRefFor(tabID); ok {
meta.Remote = &ref
for {
a.mu.RLock()
tab := a.tabByIDLocked(tabID)
snap := snapshotTabRuntimeLocked(tab)
runtimeView := a.sessionRuntimeViewLocked(tab)
a.mu.RUnlock()
if tab == nil {
meta := Meta{EventChannel: eventChannel}
if ref, ok := a.remoteTabRefFor(tabID); ok {
meta.Remote = &ref
}
return meta
}
cwd := snap.workspaceRoot
if cwd == "" {
cwd, _ = os.Getwd()
}
// Git branch and image-input capability come from the per-tab cache
// refreshed in the background (refreshTabMetaExtras); computing them here
// put a config load + model resolution on every meta request. A miss or
// stale entry schedules a refresh and serves the last known values (empty
// on the very first call; the "tab:meta" event delivers the refresh).
extras, refreshExtras := tabMetaExtrasFor(tab, cwd, snap.model)
// Native image routing is already frozen in the Controller. Reading this
// cheap snapshot also makes rebuilds visible immediately, without mixing
// newly saved config with a provider from the preceding runtime generation.
if capability, ok := snap.ctrl.(interface{ ImageInputSnapshot() (bool, bool, bool) }); ok {
if enabled, fallback, available := capability.ImageInputSnapshot(); available {
extras.imageInputEnabled, extras.visionFallbackEnabled = enabled, fallback
}
}
if refreshExtras {
a.scheduleTabMetaExtrasRefresh(tab.ID)
}
autoApproveTools := snap.ctrl != nil && snap.ctrl.AutoApproveTools()
collaborationMode := snap.collaborationMode()
toolApprovalMode := snap.currentToolApprovalMode()
// Deprecated dual-write wire values: pinned so one-version-old frontends
// keep parsing meta; nothing branches on them anymore.
tokenMode := boot.TokenModeFull
agentPreset := boot.AgentPresetBalanced
goal := snap.currentGoal()
goalStatus := snap.currentGoalStatus()
var runtimeStateSnapshot *event.RuntimeStateSnapshot
var goalView *goaldomain.View
var canonicalTodos *[]evidence.TodoItem
if snap.ctrl != nil {
state := controllerRuntimeState(snap.ctrl)
if state.SchemaVersion == 1 {
runtimeStateSnapshot = &state
goalView = state.Goal
canonicalTodos = runtimeSnapshotTodos(state)
} else {
// Compatibility adapter for old controller implementations. Current
// controllers expose the single runtime snapshot above.
canonicalTodos = ctrlTodos(snap.ctrl)
}
}
sessionPath := strings.TrimSpace(snap.sessionPath)
sessionID := strings.TrimSpace(snap.sessionID)
var sessionRef *session.SessionRef
if sessionID != "" {
ref := session.SessionRef{HostID: localDesktopHostID, SessionID: sessionID}
sessionRef = &ref
}
var sessionRevision int64
var sessionDigest string
if branchMeta, ok, err := agent.LoadBranchMeta(sessionPath); err == nil && ok {
sessionRevision = branchMeta.Revision
sessionDigest = branchMeta.ContentDigest
}
meta := Meta{
Label: snap.label,
Ready: runtimeView.Phase == sessionRuntimeReady && snap.ctrl != nil,
Runtime: runtimeView,
StartupErr: snap.startupErr,
EventChannel: eventChannel,
SessionPath: sessionPath,
SessionID: sessionID,
Session: sessionRef,
SessionRevision: sessionRevision,
SessionDigest: sessionDigest,
SessionGeneration: snap.sessionGeneration,
RuntimeStateSnapshot: runtimeStateSnapshot,
Cwd: cwd,
WorkspaceRoot: cwd,
WorkspaceName: tabWorkspaceNameForScope(snap.scope, cwd),
WorkspacePath: cwd,
GitBranch: extras.gitBranch,
ImageInputEnabled: extras.imageInputEnabled,
VisionFallbackEnabled: extras.visionFallbackEnabled,
AutoApproveTools: autoApproveTools,
Bypass: autoApproveTools,
CollaborationMode: collaborationMode,
TokenMode: tokenMode,
AgentPreset: agentPreset,
ToolApprovalMode: toolApprovalMode,
Goal: goal,
GoalStatus: goalStatus,
GoalView: goalView,
GoalRuntime: goalRuntimeViewFromController(snap.ctrl),
CanonicalTodos: canonicalTodos,
PinnedFiles: buildPinnedContext(snap.workspaceRoot, tab.GetPinnedFiles()).Infos,
}
a.mu.RLock()
currentTab := a.tabByIDLocked(tabID)
current := snapshotTabRuntimeLocked(currentTab)
valid := currentTab == tab && sameSessionAPI(current.ctrl, snap.ctrl) &&
current.sessionID == snap.sessionID && current.sessionPath == snap.sessionPath &&
current.sessionGeneration == snap.sessionGeneration
a.mu.RUnlock()
if !valid {
continue
}
return meta
}
cwd := snap.workspaceRoot
if cwd == "" {
cwd, _ = os.Getwd()
}
// Git branch and image-input capability come from the per-tab cache
// refreshed in the background (refreshTabMetaExtras); computing them here
// put a config load + model resolution on every meta request. A miss or
// stale entry schedules a refresh and serves the last known values (empty
// on the very first call; the "tab:meta" event delivers the refresh).
extras, refreshExtras := tabMetaExtrasFor(tab, cwd, snap.model)
// Native image routing is already frozen in the Controller. Reading this
// cheap snapshot also makes rebuilds visible immediately, without mixing
// newly saved config with a provider from the preceding runtime generation.
if capability, ok := snap.ctrl.(interface{ ImageInputSnapshot() (bool, bool, bool) }); ok {
if enabled, fallback, available := capability.ImageInputSnapshot(); available {
extras.imageInputEnabled, extras.visionFallbackEnabled = enabled, fallback
}
}
if refreshExtras {
a.scheduleTabMetaExtrasRefresh(tab.ID)
}
autoApproveTools := snap.ctrl != nil && snap.ctrl.AutoApproveTools()
collaborationMode := snap.collaborationMode()
toolApprovalMode := snap.currentToolApprovalMode()
// Deprecated dual-write wire values: pinned so one-version-old frontends
// keep parsing meta; nothing branches on them anymore.
tokenMode := boot.TokenModeFull
agentPreset := boot.AgentPresetBalanced
goal := snap.currentGoal()
goalStatus := snap.currentGoalStatus()
var goalView *goaldomain.View
if reader, ok := snap.ctrl.(control.RuntimeStateReader); ok {
goalView = reader.RuntimeStateSnapshot().Goal
}
sessionPath := strings.TrimSpace(snap.sessionPath)
sessionID := strings.TrimSpace(snap.sessionID)
var sessionRef *session.SessionRef
if sessionID != "" {
ref := session.SessionRef{HostID: localDesktopHostID, SessionID: sessionID}
sessionRef = &ref
}
var sessionRevision int64
var sessionDigest string
if branchMeta, ok, err := agent.LoadBranchMeta(sessionPath); err == nil && ok {
sessionRevision = branchMeta.Revision
sessionDigest = branchMeta.ContentDigest
}
return Meta{
Label: snap.label,
Ready: runtimeView.Phase == sessionRuntimeReady && snap.ctrl != nil,
Runtime: runtimeView,
StartupErr: snap.startupErr,
EventChannel: eventChannel,
SessionPath: sessionPath,
SessionID: sessionID,
Session: sessionRef,
SessionRevision: sessionRevision,
SessionDigest: sessionDigest,
Cwd: cwd,
WorkspaceRoot: cwd,
WorkspaceName: tabWorkspaceNameForScope(snap.scope, cwd),
WorkspacePath: cwd,
GitBranch: extras.gitBranch,
ImageInputEnabled: extras.imageInputEnabled,
VisionFallbackEnabled: extras.visionFallbackEnabled,
AutoApproveTools: autoApproveTools,
Bypass: autoApproveTools,
CollaborationMode: collaborationMode,
TokenMode: tokenMode,
AgentPreset: agentPreset,
ToolApprovalMode: toolApprovalMode,
Goal: goal,
GoalStatus: goalStatus,
GoalView: goalView,
GoalRuntime: goalRuntimeViewFromController(snap.ctrl),
CanonicalTodos: ctrlTodos(snap.ctrl),
PinnedFiles: buildPinnedContext(snap.workspaceRoot, tab.GetPinnedFiles()).Infos,
}
func runtimeSnapshotTodos(state event.RuntimeStateSnapshot) *[]evidence.TodoItem {
todos := make([]evidence.TodoItem, len(state.Todos))
for i, todo := range state.Todos {
todos[i] = evidence.TodoItem{Content: todo.Content, Status: todo.Status}
}
return &todos
}
// ctrlTodos returns the canonical task list from a session controller, or nil
+50
View File
@@ -27,6 +27,19 @@ type PromptIdentityView struct {
Kind string `json:"kind"`
}
type InteractionTargetView struct {
TabID string `json:"tabId"`
HostID string `json:"hostId"`
SessionID string `json:"sessionId"`
SessionGeneration uint64 `json:"sessionGeneration"`
PromptID string `json:"promptId"`
TurnID string `json:"turnId"`
RuntimeEpoch string `json:"runtimeEpoch"`
Kind string `json:"kind"`
}
type RemotePromptTarget = InteractionTargetView
func (a *App) PendingPromptIdentitiesForTab(tabID string) ([]PromptIdentityView, error) {
tab, ctrl := a.tabAndCtrlByID(tabID)
if ctrl == nil {
@@ -75,6 +88,43 @@ func (a *App) ResolvePromptForTab(tabID, promptID, turnID, runtimeEpoch, kind st
)
}
// ResolvePromptForSession fixes the controller and session binding before
// dispatch. A reusable tab cannot redirect an old card to its new session.
func (a *App) ResolvePromptForSession(target InteractionTargetView, answer PromptAnswerView) error {
if target.TabID == "" || target.SessionID == "" || target.SessionGeneration == 0 ||
target.PromptID == "" || target.TurnID == "" || target.Kind == "" {
return fmt.Errorf("exact prompt and session identity is required")
}
if target.HostID != "" && target.HostID != localDesktopHostID {
return fmt.Errorf("prompt host binding is stale")
}
a.mu.RLock()
tab := a.tabByIDLocked(target.TabID)
if tab == nil || tab.SessionID != target.SessionID || tab.SessionGeneration != target.SessionGeneration {
a.mu.RUnlock()
return fmt.Errorf("prompt session binding is stale")
}
ctrl := tab.Ctrl
a.mu.RUnlock()
if ctrl == nil {
return fmt.Errorf("prompt runtime is unavailable")
}
resolver, ok := ctrl.(interface {
ResolvePromptExact(control.PromptIdentity, control.PromptAnswer) error
})
if !ok {
return fmt.Errorf("this runtime cannot resolve exact prompts")
}
questions := make([]event.AskAnswer, len(answer.Questions))
for i, q := range answer.Questions {
questions[i] = event.AskAnswer{QuestionID: q.QuestionID, Selected: q.Selected}
}
return resolver.ResolvePromptExact(
control.PromptIdentity{PromptID: target.PromptID, TurnID: target.TurnID, RuntimeEpoch: target.RuntimeEpoch, Kind: control.PromptKind(target.Kind)},
control.PromptAnswer{Questions: questions, Allow: answer.Allow, Session: answer.Session, Persist: answer.Persist, Action: answer.Action, Feedback: answer.Feedback, Content: answer.Content, Generation: answer.Generation, PermissionRevision: answer.PermissionRevision},
)
}
// ApproveTabForTurn is the exact-identity approval entry point for new
// frontends. The legacy ApproveTab method remains available to old clients.
func (a *App) ApproveTabForTurn(tabID, turnID, runtimeEpoch, id string, allow, session, persist bool) error {
+39
View File
@@ -31,6 +31,21 @@ type extensionFormSubmitter interface {
SubmitExtensionForm(ctx context.Context, pluginID, surfaceID string, values map[string]any) error
}
type exactExtensionFormSubmitter interface {
SubmitExtensionFormExact(ctx context.Context, pluginID, surfaceID string, generation uint64, formInstanceID string, values map[string]any) error
}
type ExtensionFormTarget struct {
TabID string `json:"tabId"`
HostID string `json:"hostId"`
SessionID string `json:"sessionId"`
SessionGeneration uint64 `json:"sessionGeneration"`
PluginID string `json:"pluginId"`
SurfaceID string `json:"surfaceId"`
PluginGeneration uint64 `json:"pluginGeneration"`
FormInstanceID string `json:"formInstanceId"`
}
// extensionActionsForCtrl maps the control port's action views to their JSON
// twins. Nil controller → empty, so the palette simply shows no extension
// group while a tab's runtime is still starting.
@@ -105,3 +120,27 @@ func (a *App) SubmitExtensionForm(tabID, pluginID, surfaceID string, values map[
}
return submitter.SubmitExtensionForm(a.bootContext(), pluginID, surfaceID, values)
}
func (a *App) SubmitExtensionFormExact(target ExtensionFormTarget, values map[string]any) error {
if strings.TrimSpace(target.TabID) == "" || strings.TrimSpace(target.PluginID) == "" ||
strings.TrimSpace(target.SurfaceID) == "" || strings.TrimSpace(target.FormInstanceID) == "" {
return fmt.Errorf("exact extension form identity is required")
}
if target.HostID != "" && target.HostID != localDesktopHostID {
return fmt.Errorf("extension form host binding is stale")
}
a.mu.RLock()
tab := a.tabByIDLocked(target.TabID)
if tab == nil || tab.SessionID != target.SessionID || tab.SessionGeneration != target.SessionGeneration {
a.mu.RUnlock()
return fmt.Errorf("extension form binding is stale")
}
ctrl := tab.Ctrl
a.mu.RUnlock()
submitter, ok := ctrl.(exactExtensionFormSubmitter)
if !ok {
return fmt.Errorf("exact extension form submission is unavailable on this runtime")
}
return submitter.SubmitExtensionFormExact(a.bootContext(), target.PluginID, target.SurfaceID,
target.PluginGeneration, target.FormInstanceID, values)
}
@@ -108,8 +108,8 @@ async function cleanup(root: Root, dom: JSDOM) {
console.log("\napproval shelf animation");
// A real Web Animations implementation validates easing synchronously. Keep
// the business action pending until the visual transition finishes.
// A real Web Animations implementation validates easing synchronously. The
// decision starts immediately; the transition has no business ownership.
{
const dom = installDom();
const answers: SubmittedAnswer[] = [];
@@ -129,7 +129,7 @@ console.log("\napproval shelf animation");
await confirmSelectedAction();
eq(easing, "cubic-bezier(0.8, 0, 0.8, 0.28)", "shelf exit passes a valid CSS easing to Element.animate");
eq(answers.length, 0, "approval waits for the shelf exit animation");
eq(answers.length, 1, "approval submits before the shelf exit animation finishes");
eq(animations.length, 1, "approval starts one shelf exit animation");
await act(async () => {
@@ -137,7 +137,7 @@ console.log("\napproval shelf animation");
animations[0].oncancel?.();
await flushTimers();
});
eq(answers.length, 1, "finish and late cancel submit the approval only once");
eq(answers.length, 1, "finish and late cancel do not resubmit the approval");
eq(JSON.stringify(answers[0]), JSON.stringify([true, false, false]), "finished animation preserves the selected approval");
await cleanup(root, dom);
@@ -126,6 +126,7 @@ console.log("\nask card layout");
React.createElement(LocaleProvider, null,
React.createElement(AskCard, {
ask,
draftScope: `test:${ask.id}`,
onAnswer: (_id: string, next: QuestionAnswer[]) => { answers.push(next); },
onDismiss: () => undefined,
onStop: () => undefined,
@@ -285,6 +286,7 @@ console.log("\nask card layout");
React.createElement(LocaleProvider, null,
React.createElement(AskCard, {
ask,
draftScope: `test:${ask.id}`,
onAnswer: (_id: string, next: QuestionAnswer[]) => { answers.push(next); },
onDismiss: () => undefined,
onStop: () => undefined,
@@ -354,6 +356,7 @@ console.log("\nask card layout");
React.createElement(LocaleProvider, null,
React.createElement(AskCard, {
ask,
draftScope: `test:${ask.id}`,
onAnswer: (_id: string, next: QuestionAnswer[]) => { answers.push(next); },
onDismiss: () => undefined,
onStop: () => undefined,
@@ -407,6 +410,7 @@ console.log("\nask card layout");
React.createElement(LocaleProvider, null,
React.createElement(AskCard, {
ask,
draftScope: `test:${ask.id}`,
onAnswer: (_id: string, next: QuestionAnswer[]) => { answers.push(next); },
onDismiss: () => undefined,
onStop: () => undefined,
@@ -463,6 +467,7 @@ console.log("\nask card layout");
React.createElement(LocaleProvider, null,
React.createElement(AskCard, {
ask,
draftScope: `test:${ask.id}`,
onAnswer: (_id: string, next: QuestionAnswer[]) => { answers.push(next); },
onDismiss: () => undefined,
onStop: () => undefined,
@@ -501,6 +506,7 @@ console.log("\nask card layout");
root.render(React.createElement(LocaleProvider, null,
React.createElement(AskCard, {
ask,
draftScope: `test:${ask.id}`,
onAnswer: (_id: string, next: QuestionAnswer[]) => { answers.push(next); },
onDismiss: () => undefined,
onStop: () => undefined,
@@ -565,6 +571,7 @@ console.log("\nask card layout");
React.createElement(LocaleProvider, null,
React.createElement(AskCard, {
ask,
draftScope: `test:${ask.id}`,
onAnswer: () => undefined,
onDismiss: () => undefined,
onStop: () => undefined,
@@ -618,6 +625,7 @@ console.log("\nask card layout");
React.createElement(LocaleProvider, null,
React.createElement(AskCard, {
ask,
draftScope: `test:${ask.id}`,
onAnswer: async (_id: string, answers: QuestionAnswer[]) => {
attempts += 1;
submitted.push(answers);
@@ -688,6 +696,7 @@ console.log("\nask card layout");
React.createElement(LocaleProvider, null,
React.createElement(AskCard, {
ask,
draftScope: `test:${ask.id}`,
onAnswer: (_id: string, answers: QuestionAnswer[]) => { submitted.push(answers); },
onDismiss: () => undefined,
onStop: () => undefined,
@@ -718,7 +727,7 @@ console.log("\nask card layout");
};
await act(async () => {
root.render(React.createElement(LocaleProvider, null,
React.createElement(AskCard, { ask, onAnswer: () => undefined, onDismiss: () => undefined, onStop: () => undefined }),
React.createElement(AskCard, { ask, draftScope: `test:${ask.id}`, onAnswer: () => undefined, onDismiss: () => undefined, onStop: () => undefined }),
));
await flushTimers();
});
@@ -2,7 +2,7 @@
import type { AppBindings } from "../lib/bridge";
import { answerPromptForActiveTurn } from "../lib/inboxSubmit";
import type { QuestionAnswer, TabMeta } from "../lib/types";
import type { QuestionAnswer } from "../lib/types";
let passed = 0;
let failed = 0;
@@ -22,30 +22,28 @@ function binding(overrides: Partial<AppBindings>): AppBindings {
}
const answers: QuestionAnswer[] = [{ questionId: "q1", selected: ["yes"] }];
const tab = { id: "tab-ask", turnId: "turn-authoritative" } as TabMeta;
console.log("\nAsk prompt active-turn submission");
console.log("\nAsk prompt exact-request submission");
{
let listCalls = 0;
let pendingCalls = 0;
const exactCalls: string[] = [];
await answerPromptForActiveTurn(binding({
ListTabs: async () => { listCalls += 1; return [tab]; },
PendingPromptIdentitiesForTab: async () => { pendingCalls += 1; return []; },
ResolvePromptForTab: async (tabId, promptId, turnId) => { exactCalls.push(`${tabId}:${turnId}:${promptId}`); },
}), "tab-ask", "ask-1", answers, "turn-known");
eq(listCalls, 1, "Ask refreshes the authoritative turn before submission");
eq(exactCalls.join("|"), "tab-ask:turn-authoritative:ask-1", "authoritative turn fences the exact answer");
eq(pendingCalls, 0, "complete Ask identity does not query the current turn");
eq(exactCalls.join("|"), "tab-ask:turn-known:ask-1", "the card's original turn fences the exact answer");
}
{
let listCalls = 0;
let pendingCalls = 0;
const exactCalls: string[] = [];
await answerPromptForActiveTurn(binding({
ListTabs: async () => { listCalls += 1; return [tab]; },
PendingPromptIdentitiesForTab: async () => { pendingCalls += 1; return [{ promptId: "ask-2", turnId: "turn-original", runtimeEpoch: "runtime-a", kind: "ask" }]; },
ResolvePromptForTab: async (tabId, promptId, turnId) => { exactCalls.push(`${tabId}:${turnId}:${promptId}`); },
}), "tab-ask", "ask-2", answers);
eq(listCalls, 1, "missing local turn id is resolved from ListTabs once");
eq(exactCalls.join("|"), "tab-ask:turn-authoritative:ask-2", "resolved turn id fences the exact answer");
eq(pendingCalls, 1, "missing local identity queries pending prompts once");
eq(exactCalls.join("|"), "tab-ask:turn-original:ask-2", "only the matching pending Ask can complete identity");
}
{
@@ -53,13 +51,13 @@ console.log("\nAsk prompt active-turn submission");
let rejected = "";
try {
await answerPromptForActiveTurn(binding({
ListTabs: async () => [],
PendingPromptIdentitiesForTab: async () => [],
ResolvePromptForTab: async () => { exactCalls += 1; },
}), "tab-ask", "ask-3", answers);
} catch (error) {
rejected = error instanceof Error ? error.message : String(error);
}
eq(rejected.includes("active turn identity is unavailable"), true, "missing authoritative turn id rejects visibly");
eq(rejected.includes("stale") || rejected.includes("exact identity"), true, "missing exact prompt identity rejects visibly");
eq(exactCalls, 0, "missing turn id never calls the exact endpoint with an empty fence");
}
@@ -68,13 +66,13 @@ console.log("\nAsk prompt active-turn submission");
let rejected = "";
try {
await answerPromptForActiveTurn(binding({
ListTabs: async () => { throw new Error("ListTabs failed"); },
PendingPromptIdentitiesForTab: async () => { throw new Error("pending lookup failed"); },
ResolvePromptForTab: async () => { exactCalls += 1; },
}), "tab-ask", "ask-rpc", answers);
} catch (error) {
rejected = error instanceof Error ? error.message : String(error);
}
eq(rejected, "ListTabs failed", "authoritative turn lookup failure propagates");
eq(rejected, "pending lookup failed", "pending identity lookup failure propagates");
eq(exactCalls, 0, "failed turn lookup never calls the exact endpoint");
}
@@ -82,9 +80,8 @@ console.log("\nAsk prompt active-turn submission");
let rejected = "";
try {
await answerPromptForActiveTurn(binding({
ListTabs: async () => [tab],
ResolvePromptForTab: async () => { throw new Error("stale turn"); },
}), "tab-ask", "ask-4", answers);
}), "tab-ask", "ask-4", answers, "turn-original", "runtime-a");
} catch (error) {
rejected = error instanceof Error ? error.message : String(error);
}
@@ -94,11 +91,11 @@ console.log("\nAsk prompt active-turn submission");
{
let rejected = "";
try {
await answerPromptForActiveTurn(binding({ ListTabs: async () => [tab] }), "tab-ask", "ask-legacy", answers);
await answerPromptForActiveTurn(binding({}), "tab-ask", "ask-legacy", answers, "turn-original", "runtime-a");
} catch (error) {
rejected = error instanceof Error ? error.message : String(error);
}
eq(rejected.includes("active turn identity is unavailable"), true, "missing exact prompt API fails closed");
eq(rejected.includes("upgrade the host"), true, "missing exact prompt API fails closed");
}
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
@@ -1,5 +1,6 @@
import assert from "node:assert/strict";
import { initialState, reducer, type State } from "../lib/useController";
import { sessionIdentityStableKey } from "../lib/sessionIdentity";
const originalNow = Date.now;
let now = 1_000;
@@ -71,6 +72,7 @@ try {
now = 5_000;
const bothPrompts: State = { ...initialState, running: true, turnActive: true,
turnStartAt: 1_000, promptWaitStartedAt: 1_000,
meta: { label: "", ready: true, eventChannel: "agent:event", cwd: "", session: { hostId: "local", sessionId: "session-a" }, sessionGeneration: 1 },
approval: { id: "a1", tool: "write_file", subject: "Run command" },
mcpInteraction: { id: "m1", server: "srv", mode: "form", message: "Fill the form" } };
const approvalCleared = reducer(bothPrompts, { type: "clearApproval" });
@@ -80,7 +82,8 @@ try {
"an outstanding MCP interaction still counts as a pending prompt");
now = 9_000;
const mcpAnswered = reducer(approvalCleared, {
type: "expire_prompt", id: "m1", epoch: approvalCleared.promptEpoch, kind: "mcp" });
type: "expire_prompt", target: { tabId: "tab-a", sessionKey: sessionIdentityStableKey(bothPrompts.meta), hostId: "local", sessionId: "session-a", sessionGeneration: 1,
promptId: "m1", kind: "mcp", instanceKey: "mcp-1" }, epoch: approvalCleared.promptEpoch });
assert.equal(mcpAnswered.promptWaitStartedAt, undefined, "the last prompt closes the interval");
assert.equal(mcpAnswered.turnWaitAccumMs, 8_000, "the whole MCP wait is charged exactly once");
@@ -112,11 +112,15 @@ ok(acceptsExtensionGeneration(5, undefined), "events without a generation always
{
let s: ControllerState = { ...initialState };
s = reducer(s, { type: "event", e: surfaceEvent({ kind: "form", surfaceId: "f1", generation: 9, form: { title: "Setup", fields: [{ key: "name", kind: "input", required: true }] } }) });
ok(s.extensionForm?.pluginId === "alpha" && s.extensionForm.form.title === "Setup", "form surface arms the pending form");
s = reducer(s, { type: "event", e: surfaceEvent({ kind: "form", surfaceId: "f1", generation: 9, formInstanceId: "form-1", form: { title: "Setup", fields: [{ key: "name", kind: "input", required: true }] } }) });
ok(s.extensionForm?.pluginId === "alpha" && s.extensionForm.form.title === "Setup" && s.extensionForm.formInstanceExact,
"form surface arms the pending form with its host-issued instance identity");
s = reducer(s, { type: "event", e: surfaceEvent({ kind: "form", surfaceId: "f1", generation: 4, form: { title: "Stale", fields: [] } }) });
ok(s.extensionForm?.form.title === "Setup", "stale form generation is dropped");
s = reducer(s, { type: "event", e: surfaceEvent({ kind: "form", surfaceId: "f2", generation: 10, form: { title: "Next", fields: [] } }) });
s = reducer(s, { type: "event", e: surfaceEvent({ kind: "form", surfaceId: "f1", generation: 9, formInstanceId: "form-2", form: { title: "Replacement", fields: [] } }) });
s = reducer(s, { type: "clearExtensionForm", identity: { pluginId: "alpha", surfaceId: "f1", formInstanceId: "form-1" } });
ok(s.extensionForm?.formInstanceId === "form-2", "old completion cannot clear a replacement form instance");
s = reducer(s, { type: "event", e: surfaceEvent({ kind: "form", surfaceId: "f2", generation: 10, formInstanceId: "form-3", form: { title: "Next", fields: [] } }) });
ok(s.extensionForm?.surfaceId === "f2", "a new form replaces the pending one");
s = reducer(s, { type: "clearExtensionForm" });
ok(s.extensionForm === undefined, "clearExtensionForm dismisses the form");
@@ -290,6 +294,8 @@ const cardItem: ExtensionItem = {
pluginId: "alpha",
surfaceId: "f1",
generation: 9,
formInstanceId: "form-1",
formInstanceExact: true,
form: {
title: "Configure sync",
fields: [
@@ -303,7 +309,7 @@ const cardItem: ExtensionItem = {
await act(async () => {
root.render(
<LocaleProvider>
<ExtensionFormDialog surface={surface} onSubmit={(values) => submitted.push(values)} onCancel={() => { cancels += 1; }} />
<ExtensionFormDialog key={surface.formInstanceId} surface={surface} onSubmit={(values) => submitted.push(values)} onCancel={() => { cancels += 1; }} />
</LocaleProvider>,
);
await flush();
@@ -349,6 +355,16 @@ const cardItem: ExtensionItem = {
});
ok(cancels === 1, "Escape cancels the form");
await act(async () => {
root.render(
<LocaleProvider>
<ExtensionFormDialog key="form-2" surface={{ ...surface, formInstanceId: "form-2" }} onSubmit={(next) => submitted.push(next)} onCancel={() => { cancels += 1; }} />
</LocaleProvider>,
);
await flush();
});
ok(container.querySelector<HTMLInputElement>(".extension-form__input")?.value === "", "a replacement instance resets form field state");
await act(async () => root.unmount());
container.remove();
}
@@ -0,0 +1,15 @@
import assert from "node:assert/strict";
import { createInteractionDraftStore } from "../lib/interactionDraftStore";
const store = createInteractionDraftStore<number>(2);
store.write("session-a\u0000ask-1", 1);
store.write("session-a\u0000ask-2", 2);
assert.equal(store.read("session-a\u0000ask-1"), 1);
store.write("session-b\u0000ask-1", 3);
assert.equal(store.read("session-a\u0000ask-2"), undefined, "least-recent request is evicted at the bound");
assert.equal(store.read("session-a\u0000ask-1"), 1, "same request survives component remount");
assert.equal(store.read("session-b\u0000ask-1"), 3, "reused prompt ids remain scoped by session");
store.releaseScope("session-a");
assert.equal(store.read("session-a\u0000ask-1"), undefined, "session release removes only its drafts");
assert.equal(store.read("session-b\u0000ask-1"), 3);
console.log("interaction draft store: bounded request identity and session release passed");
@@ -201,6 +201,7 @@ globalThis.MouseEvent = dom.window.MouseEvent;
root.render(
<LocaleProvider>
<MCPInteractionCard
instanceKey="test:7"
interaction={{
id: "7",
server: "github",
@@ -260,6 +261,7 @@ globalThis.MouseEvent = dom.window.MouseEvent;
root.render(
<LocaleProvider>
<MCPInteractionCard
instanceKey="test:unsupported"
interaction={{
id: "unsupported",
server: "future-server",
@@ -289,6 +291,7 @@ globalThis.MouseEvent = dom.window.MouseEvent;
root.render(
<LocaleProvider>
<MCPInteractionCard
instanceKey="test:8"
interaction={{
id: "8",
server: "calendar-with-a-deliberately-long-server-name",
@@ -326,6 +329,7 @@ globalThis.MouseEvent = dom.window.MouseEvent;
root.render(
<LocaleProvider>
<MCPInteractionCard
instanceKey="test:9"
interaction={{
id: "9",
server: "linear",
@@ -368,5 +372,67 @@ globalThis.MouseEvent = dom.window.MouseEvent;
});
}
// ── Request-instance replacement ─────────────────────────────────────────────
{
const root = createRoot(document.getElementById("root")!);
const interaction = {
id: "reused-form-id",
server: "identity-server",
mode: "form" as const,
message: "Enter an account",
requestedSchema: {
type: "object",
required: ["account"],
properties: {
account: { type: "string", title: "Account", format: "email" },
remember: { type: "boolean", title: "Remember this account" },
},
},
};
const render = (instanceKey: string) => (
<LocaleProvider>
<MCPInteractionCard instanceKey={instanceKey} interaction={interaction} busy={false} onAnswer={() => {}} />
</LocaleProvider>
);
await act(async () => root.render(render("request:first")));
const firstInput = document.querySelector(".structured-form-control") as HTMLInputElement;
const firstCheckbox = document.querySelector(".structured-form-checkbox") as HTMLInputElement;
await act(async () => firstCheckbox.click());
const submit = Array.from(document.querySelectorAll("button")).find((button) => button.textContent === "Submit");
await act(async () => submit?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
ok(firstCheckbox.checked && firstInput.getAttribute("aria-invalid") === "true", "first MCP request owns its field value and validation error");
await act(async () => root.render(render("request:replacement")));
const replacementInput = document.querySelector(".structured-form-control") as HTMLInputElement;
const replacementCheckbox = document.querySelector(".structured-form-checkbox") as HTMLInputElement;
ok(!replacementCheckbox.checked && replacementInput.getAttribute("aria-invalid") !== "true", "same MCP id/schema resets values and errors for a new request instance");
await act(async () => root.unmount());
}
{
const root = createRoot(document.getElementById("root")!);
const interaction = {
id: "reused-url-id",
server: "identity-server",
mode: "url" as const,
message: "Authorize access",
url: "https://auth.example.com/reused",
};
const render = (instanceKey: string) => (
<LocaleProvider>
<MCPInteractionCard instanceKey={instanceKey} interaction={interaction} busy={false} onAnswer={() => {}} onOpenLink={() => {}} />
</LocaleProvider>
);
await act(async () => root.render(render("url:first")));
const open = Array.from(document.querySelectorAll("button")).find((button) => (button.textContent ?? "").startsWith("Open "));
await act(async () => open?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
ok(document.querySelector(".prompt-shelf-bar-hint")?.textContent === "Finish in your browser, then accept.", "first MCP URL request records that its link was opened");
await act(async () => root.render(render("url:replacement")));
ok(document.querySelector(".prompt-shelf-bar-hint")?.textContent === "Open the link, finish in your browser, then accept.", "same MCP id resets opened-link state for a new request instance");
await act(async () => root.unmount());
}
process.stdout.write(`\n${passed} passed, ${failed} failed\n`);
if (failed > 0) process.exit(1);
@@ -11,6 +11,7 @@ import type { BalanceInfo, CheckpointMeta, ContextInfo, EffortInfo, HistoryMessa
import { installDesktopHostStub } from "./desktopHostStub";
import { meta, tabMeta } from "./helpers/sessionSwitchFixtures";
import { resetSessionDiagnostics, sessionPipelineDiagnostics } from "../lib/sessionDiagnostics";
import { runtimeStateStore, type RuntimeState } from "../lib/runtimeStateStore";
let passed = 0;
let failed = 0;
@@ -749,6 +750,46 @@ await act(async () => {
eq(controller?.state.items[0]?.text, "/sessions/fast.jsonl", "a superseded switch cannot paint over the newer transcript");
eq(sessionPipelineDiagnostics().resumeSwitch?.totalMs, 4, "superseded response cannot overwrite current switch diagnostics");
// The application runtime feed can lag behind a completed navigation RPC.
// A reused tab must not project the source session's todos onto the target.
const pausedTodos = [{ content: "Finish session A", status: "in_progress" }];
const todoSessionA = "/sessions/todo-a.jsonl";
const todoSessionB = "/sessions/todo-b.jsonl";
desktopStub.commands.MetaForTab = async () => meta({
sessionPath: switchMetaPath,
sessionGeneration: 1,
canonicalTodos: switchMetaPath === todoSessionA ? pausedTodos : [],
});
let runtimeRevision = 0;
function publishTodoRuntime(sessionPath: string, todos: typeof pausedTodos) {
const state: RuntimeState = {
schemaVersion: 1, runtimeEpoch: "todo-runtime", activityRevision: 1, revision: ++runtimeRevision,
phase: "idle", running: false, turnId: "paused-turn", turnStatus: "interrupted", turnEventSeq: 1,
pendingPrompt: false, cancelRequested: false, cancellable: false, backgroundJobs: 0, activity: "",
todos,
};
runtimeStateStore.commit({ epoch: "todo-app", revision: runtimeRevision, topics: [], sessions: [{
tabId: "tab-switch", scope: "project", workspaceRoot: "/repo", topicId: "topic-a",
sessionPath, sessionGeneration: 1, open: true, remote: false, freshness: "synced", state,
}] });
}
await act(async () => {
await controller?.resumeSession(todoSessionA, "tab-switch").surfaceReady;
publishTodoRuntime(todoSessionA, pausedTodos);
});
eq(controller?.state.meta?.canonicalTodos?.[0]?.content, "Finish session A", "paused A owns its unfinished todo");
await act(async () => { await controller?.resumeSession(todoSessionB, "tab-switch").surfaceReady; });
eq(controller?.state.meta?.canonicalTodos?.length, 0, "B stays empty while A's runtime snapshot is still current in the feed");
await act(async () => { publishTodoRuntime(todoSessionB, []); });
await act(async () => { await controller?.resumeSession(todoSessionA, "tab-switch").surfaceReady; });
eq(controller?.state.meta?.canonicalTodos?.[0]?.content, "Finish session A", "returning to A cannot be cleared by B's delayed empty snapshot");
await act(async () => { publishTodoRuntime(todoSessionB, []); });
eq(controller?.state.meta?.canonicalTodos?.[0]?.content, "Finish session A", "late B publication after the return to A cannot erase A's todo");
await act(async () => { publishTodoRuntime(todoSessionA, [{ content: "Finish session A", status: "completed" }]); });
eq(controller?.state.meta?.canonicalTodos?.[0]?.status, "completed", "a matching runtime snapshot still publishes live todo progress");
await act(async () => { publishTodoRuntime(todoSessionA, []); });
eq(controller?.state.meta?.canonicalTodos?.length, 0, "an authoritative empty list for the same session still clears todos");
await act(async () => {
switchRoot.unmount();
});
@@ -1,4 +1,6 @@
import { initialState, reducer } from "../lib/useController";
import { interactionInstanceKey } from "../lib/interactionTarget";
import { sessionIdentityStableKey } from "../lib/sessionIdentity";
let passed = 0;
let failed = 0;
@@ -16,13 +18,26 @@ function eq(actual: unknown, expected: unknown, label: string) {
const state = reducer({
...initialState,
promptEpoch: 7,
meta: { label: "", ready: true, eventChannel: "agent:event", cwd: "", session: { hostId: "local", sessionId: "session-a" }, sessionGeneration: 1 },
ask: { id: "ask-old", questions: [] },
mcpInteraction: { id: "mcp-new", server: "server", mode: "form", message: "new" },
pendingPrompt: true,
}, { type: "expire_prompt", id: "ask-old", epoch: 7, kind: "ask" });
}, { type: "expire_prompt", target: { tabId: "tab-a", sessionKey: sessionIdentityStableKey({ session: { hostId: "local", sessionId: "session-a" }, sessionGeneration: 1 }), hostId: "local", sessionId: "session-a", sessionGeneration: 1,
promptId: "ask-old", kind: "ask", instanceKey: "ask-old" }, epoch: 7 });
eq(state.ask, undefined, "stale Ask expiry removes only its matching card");
eq(state.mcpInteraction?.id, "mcp-new", "stale Ask expiry preserves a newer MCP card");
const promptMeta = { label: "", ready: true, eventChannel: "agent:event", cwd: "", session: { hostId: "host-a", sessionId: "session-a" }, sessionGeneration: 3 };
const ask = { id: "ask-replay", turnId: "turn-a", runtimeEpoch: "runtime-a", questions: [] };
const sessionKey = sessionIdentityStableKey(promptMeta);
const targetBase = { tabId: "tab-a", sessionKey, hostId: "host-a", sessionId: "session-a", sessionGeneration: 3,
promptId: ask.id, turnId: ask.turnId, runtimeEpoch: ask.runtimeEpoch, kind: "ask" as const };
const target = { ...targetBase, instanceKey: interactionInstanceKey(targetBase) };
const answered = reducer({ ...initialState, promptEpoch: 8, meta: promptMeta, ask, pendingPrompt: true },
{ type: "ask_submit_succeeded", target, epoch: 8 });
const replayed = reducer(answered, { type: "event", e: { kind: "ask_request", turnId: ask.turnId, runtimeEpoch: ask.runtimeEpoch, ask } });
eq(replayed.ask, undefined, "a delayed replay with the same host and session identity cannot resurrect an answered Ask");
console.log(`prompt expiry: ${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
@@ -68,6 +68,8 @@ let blockApproval = false;
let releaseApproval: (() => void) | undefined;
let blockAnswer = false, failAnswer = false;
let releaseAnswer: (() => void) | undefined;
let blockExtensionForm = false;
let releaseExtensionForm: (() => void) | undefined;
let resolveRaceSnapshot: ((value: { history: unknown[]; status: unknown }) => void) | undefined;
const resolveStateRaceSnapshots: Array<(value: { history: unknown[]; status: unknown }) => void> = [];
let rotationSnapshotCalls = 0;
@@ -192,6 +194,35 @@ const desktopStub = installDesktopHostStub(({ main: { App: {
async CancelRemoteTabJobs(tabId: string, jobIds: string[]) {
tape.push(`cancel-jobs:${tabId}:${jobIds.join(",")}`);
},
async ResolveRemoteTabPromptExact(
target: Parameters<AppBindings["ResolveRemoteTabPromptExact"]>[0],
answer: Parameters<AppBindings["ResolveRemoteTabPromptExact"]>[1],
) {
if (target.kind === "plan") {
tape.push(`plan-decision:${target.tabId}:${target.promptId}:${answer.action ?? ""}:${answer.feedback ?? ""}`);
} else if (target.kind === "ask") {
tape.push(`answer:${target.tabId}:${target.promptId}:${JSON.stringify(answer.questions ?? [])}`);
if (failAnswer) throw new Error("remote answer failed");
if (blockAnswer) await new Promise<void>((resolve) => { releaseAnswer = resolve; });
} else if (target.kind === "approval" || target.kind === "recovery") {
const decision = answer.allow ? answer.persist ? "persist" : answer.session ? "session" : "allow" : "deny";
tape.push(`approve:${target.tabId}:${target.promptId}:${decision}`);
if (failApproval) throw new Error("tunnel write failed");
if (blockApproval) await new Promise<void>((resolve) => { releaseApproval = resolve; });
} else {
tape.push(`mcp-answer:${target.tabId}:${target.promptId}:${answer.action ?? ""}`);
}
desktopStub.emit(`remote-tab:${target.tabId}:event`, {
kind: "prompt_answered", itemId: target.promptId, turnId: target.turnId,
});
},
async SubmitRemoteTabExtensionFormExact(
target: Parameters<AppBindings["SubmitRemoteTabExtensionFormExact"]>[0],
values: Parameters<AppBindings["SubmitRemoteTabExtensionFormExact"]>[1],
) {
tape.push(`extension-form:${target.tabId}:${target.pluginId}:${target.surfaceId}:${JSON.stringify(values)}`);
if (blockExtensionForm) await new Promise<void>((resolve) => { releaseExtensionForm = resolve; });
},
async ApproveRemoteTab(tabId: string, callId: string, decision: string) {
tape.push(`approve:${tabId}:${callId}:${decision}`);
if (failApproval) throw new Error("tunnel write failed");
@@ -249,6 +280,10 @@ const remoteTab: TabMeta = {
mode: "normal",
active: true,
cwd: "~/app",
sessionId: "remote-session-1",
sessionGeneration: 1,
interactionTargetSupported: true,
extensionFormInstanceSupported: true,
remote: { hostId: "gpu-box", workspace: "~/app" },
};
@@ -305,7 +340,7 @@ await act(async () => {
});
await act(async () => {
__emitMockRemoteTab("tab-remote-1", "event", { kind: "approval_request", approval: { id: "call-9", tool: "bash", subject: "rm -rf /tmp/junk" } });
__emitMockRemoteTab("tab-remote-1", "event", { kind: "approval_request", turnId: "turn-main", runtimeEpoch: "runtime-main", approval: { id: "call-9", tool: "bash", subject: "rm -rf /tmp/junk" } });
await flush();
});
{
@@ -329,7 +364,7 @@ await act(async () => {
await act(async () => {
__emitMockRemoteTab("tab-remote-1", "event", {
kind: "approval_request",
kind: "approval_request", turnId: "turn-main", runtimeEpoch: "runtime-main",
approval: { id: "plan-remote", tool: "exit_plan_mode", subject: "Plan ready" },
});
await flush();
@@ -361,7 +396,7 @@ await act(async () => {
await act(async () => {
failApproval = true;
__emitMockRemoteTab("tab-remote-1", "event", { kind: "approval_request", approval: { id: "call-fail", tool: "bash", subject: "keep this prompt" } });
__emitMockRemoteTab("tab-remote-1", "event", { kind: "approval_request", turnId: "turn-main", runtimeEpoch: "runtime-main", approval: { id: "call-fail", tool: "bash", subject: "keep this prompt" } });
await flush();
});
{
@@ -382,7 +417,7 @@ await act(async () => {
}
await act(async () => {
__emitMockRemoteTab("tab-remote-1", "event", { kind: "ask_request", ask: { id: "ask-7", questions: [{ id: "q1", prompt: "Deploy now?", options: [{ label: "yes" }, { label: "no" }] }] } });
__emitMockRemoteTab("tab-remote-1", "event", { kind: "ask_request", turnId: "turn-main", runtimeEpoch: "runtime-main", ask: { id: "ask-7", questions: [{ id: "q1", prompt: "Deploy now?", options: [{ label: "yes" }, { label: "no" }] }] } });
await flush();
});
{
@@ -402,7 +437,7 @@ await act(async () => {
}
await act(async () => {
__emitMockRemoteTab("tab-remote-1", "event", { kind: "ask_request", ask: { id: "ask-custom", questions: [{ id: "q-custom", prompt: "Where?", options: [{ label: "staging" }] }] } });
__emitMockRemoteTab("tab-remote-1", "event", { kind: "ask_request", turnId: "turn-main", runtimeEpoch: "runtime-main", ask: { id: "ask-custom", questions: [{ id: "q-custom", prompt: "Where?", options: [{ label: "staging" }] }] } });
await flush();
});
await act(async () => {
@@ -421,14 +456,14 @@ await act(async () => {
[...document.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.textContent?.trim() === "Submit")?.click();
await flush();
});
ok(tape.includes('answer:tab-remote-1:ask-custom:[{"QuestionID":"q-custom","Selected":["canary"]}]'),
"custom AskCard text serializes as the remote question selection");
ok(tape.includes('answer:tab-remote-1:ask-custom:[{"questionId":"q-custom","selected":["canary"]}]'),
"custom AskCard text serializes as the exact remote question selection");
await act(async () => {
__emitMockRemoteTab("tab-remote-1", "event", {
kind: "extension_surface",
extension: {
pluginId: "remote-plugin", surfaceId: "setup", kind: "form",
pluginId: "remote-plugin", surfaceId: "setup", generation: 1, formInstanceId: "setup-instance", kind: "form",
form: { title: "Remote setup", fields: [{ key: "region", label: "Region", kind: "input", required: true, default: "us-west" }] },
},
});
@@ -446,6 +481,49 @@ await act(async () => {
ok(!document.querySelector(".extension-form"), "remote extension form clears after an accepted submission");
}
blockExtensionForm = true;
await act(async () => {
__emitMockRemoteTab("tab-remote-1", "event", {
kind: "extension_surface",
extension: {
pluginId: "remote-plugin", surfaceId: "reused", generation: 7, formInstanceId: "old-instance", kind: "form",
form: { title: "Old publication", fields: [{ key: "value", label: "Value", kind: "input", default: "old" }] },
},
});
await flush();
});
await act(async () => {
document.querySelector(".extension-form")?.closest(".prompt-shelf")?.querySelector<HTMLButtonElement>(".decision-confirm-bar__confirm")?.click();
await Promise.resolve();
});
ok(document.querySelector(".extension-form")?.closest(".prompt-shelf")?.querySelector<HTMLButtonElement>(".decision-confirm-bar__confirm")?.disabled === true,
"remote form busy state belongs to the submitting publication");
await act(async () => {
__emitMockRemoteTab("tab-remote-1", "event", {
kind: "extension_surface",
extension: {
pluginId: "remote-plugin", surfaceId: "reused", generation: 7, formInstanceId: "replacement-instance", kind: "form",
form: { title: "Replacement publication", fields: [{ key: "value", label: "Value", kind: "input", default: "new" }] },
},
});
await flush();
});
ok(document.body.textContent?.includes("Replacement publication") === true
&& document.querySelector(".extension-form")?.closest(".prompt-shelf")?.querySelector<HTMLButtonElement>(".decision-confirm-bar__confirm")?.disabled === false,
"a replacement remote form does not inherit the old instance busy state");
blockExtensionForm = false;
await act(async () => {
releaseExtensionForm?.();
await flush();
});
ok(document.body.textContent?.includes("Replacement publication") === true,
"late completion from the old form instance cannot clear its replacement");
await act(async () => {
document.querySelector(".extension-form")?.closest(".prompt-shelf")?.querySelector<HTMLButtonElement>(".decision-confirm-bar__confirm")?.click();
await flush();
});
ok(!document.querySelector(".extension-form"), "replacement remote form clears only after its own submission");
await act(async () => {
__emitMockRemoteTab("tab-remote-1", "event", { kind: "text", text: "retain this partial answer across disconnect" });
await flush();
@@ -488,6 +566,52 @@ await act(async () => { __emitMockRemoteTab("tab-remote-1", "state", { state: "d
await act(async () => root.unmount());
// Older Serve versions remain readable, but decision writes stay fenced when
// they cannot carry the complete request or form publication identity.
const legacyCapabilityContainer = document.createElement("div");
document.body.appendChild(legacyCapabilityContainer);
const legacyCapabilityRoot = createRoot(legacyCapabilityContainer);
const legacyCapabilityTab: TabMeta = {
...remoteTab,
id: "tab-legacy-capabilities",
sessionId: "legacy-session",
sessionGeneration: 1,
interactionTargetSupported: false,
extensionFormInstanceSupported: false,
};
await act(async () => {
legacyCapabilityRoot.render(<LocaleProvider><RemoteSurfaceHarness tab={legacyCapabilityTab} /></LocaleProvider>);
await flush();
});
await act(async () => {
__emitMockRemoteTab(legacyCapabilityTab.id, "event", {
kind: "approval_request",
approval: { id: "legacy-approval", tool: "bash", subject: "must remain fenced" },
});
__emitMockRemoteTab(legacyCapabilityTab.id, "event", {
kind: "extension_surface",
extension: {
pluginId: "legacy-plugin", surfaceId: "legacy-form", generation: 1, formInstanceId: "legacy-instance", kind: "form",
form: { title: "Legacy form", fields: [{ key: "value", label: "Value", kind: "input", default: "blocked" }] },
},
});
await flush();
});
const legacyApprovalButton = legacyCapabilityContainer.querySelector<HTMLButtonElement>(".remote-surface__approval .prompt-action");
const legacyFormButton = legacyCapabilityContainer.querySelector(".extension-form")?.closest(".prompt-shelf")?.querySelector<HTMLButtonElement>(".decision-confirm-bar__confirm");
ok(legacyCapabilityContainer.textContent?.includes("must be upgraded") === true, "old Serve shows an upgrade notice only for unsafe interactive cards");
ok(legacyApprovalButton?.closest("fieldset")?.disabled === true && legacyFormButton?.disabled === true, "old Serve keeps exact prompt and form actions disabled");
const legacyWritesBefore = tape.filter((entry) => entry.includes(legacyCapabilityTab.id) && (entry.startsWith("approve:") || entry.startsWith("extension-form:"))).length;
await act(async () => {
legacyApprovalButton?.click();
legacyFormButton?.click();
await flush();
});
ok(tape.filter((entry) => entry.includes(legacyCapabilityTab.id) && (entry.startsWith("approve:") || entry.startsWith("extension-form:"))).length === legacyWritesBefore,
"old Serve card clicks never fall back to legacy mutation endpoints");
await act(async () => legacyCapabilityRoot.unmount());
legacyCapabilityContainer.remove();
let restoredShellProbe: RemoteSessionApi | undefined;
function RestoredShellProbe() { restoredShellProbe = useRemoteSession("tab-restored-shell", "disconnected"); return null; }
const restoredShellRoot = createRoot(document.getElementById("root")!);
@@ -1,5 +1,5 @@
import assert from "node:assert/strict";
import { createRuntimeStateStore, selectRuntime, type RuntimeProjection, type RuntimeState } from "../lib/runtimeStateStore";
import { acceptSessionRuntimeSnapshot, createRuntimeStateStore, selectRuntime, selectRuntimeSession, type RuntimeProjection, type RuntimeState } from "../lib/runtimeStateStore";
import { startRuntimeStateSync } from "../lib/runtimeStateSync";
import { acceptRuntimeState } from "../lib/runtimeStateReducer";
@@ -8,6 +8,49 @@ const state: RuntimeState = { schemaVersion: 1, runtimeEpoch: "controller-a", ac
const projection = (revision: number, changes: Partial<RuntimeState> = {}): RuntimeProjection => ({ epoch: "app-a", revision, topics: [],
sessions: [{ tabId: "a", scope: "project", workspaceRoot: "/fixture", topicId: "topic", sessionPath: "/fixture/session", sessionGeneration: 1,
open: true, remote: false, freshness: "synced", state: { ...state, revision, ...changes } }] });
{
const versioned = (projectionEpoch: string, revision: number, todos: RuntimeState["todos"]): RuntimeState => ({
...state, projectionEpoch, revision, todos,
});
const newest = versioned("producer-a", 20, [{ content: "new", status: "in_progress" }]);
assert.equal(acceptSessionRuntimeSnapshot(newest, versioned("producer-a", 10, [])), newest,
"an old empty snapshot cannot clear a newer list");
const cleared = acceptSessionRuntimeSnapshot(newest, versioned("producer-a", 21, []));
assert.deepEqual(cleared.todos, [], "a newer empty snapshot is an authoritative clear");
const replacement = versioned("producer-b", 1, [{ content: "replacement", status: "pending" }]);
assert.equal(acceptSessionRuntimeSnapshot(cleared, replacement), cleared,
"an ordinary frame cannot replace the current producer");
assert.equal(acceptSessionRuntimeSnapshot(cleared, replacement, true), replacement,
"a binding-validated baseline can establish a replacement producer");
assert.equal(acceptSessionRuntimeSnapshot(replacement, versioned("producer-a", 99, [])), replacement,
"the retired producer cannot overwrite its replacement");
}
{
const frame = projection(1, { todos: [{ content: "A's task", status: "in_progress" }] });
const session = frame.sessions[0];
assert.equal(selectRuntimeSession(frame, "a", session.sessionPath), session);
assert.equal(selectRuntimeSession(frame, "b", session.sessionPath), undefined, "a different tab cannot borrow the runtime");
assert.equal(selectRuntimeSession(frame, "a", "/fixture/other"), undefined, "reusing a tab cannot borrow the previous session's runtime");
for (const identity of [undefined, "", {}, { sessionPath: "" }]) {
assert.equal(selectRuntimeSession(frame, "a", identity), undefined, "unbound and empty sessions never act as wildcards");
}
assert.equal(selectRuntimeSession(frame, "a", { sessionPath: session.sessionPath, sessionGeneration: 2 }), undefined, "ABA navigation rejects the old binding generation");
assert.equal(selectRuntimeSession(frame, "a", { sessionPath: session.sessionPath, sessionGeneration: 1 }), session);
session.open = false;
assert.equal(selectRuntimeSession(frame, "a", session.sessionPath), undefined, "detached runtimes are not visible sessions");
session.open = true;
session.sessionId = "canonical-a";
const canonical = { session: { hostId: "local", sessionId: "canonical-a" }, sessionPath: "session-id:canonical-a", sessionGeneration: 1 };
assert.equal(selectRuntimeSession(frame, "a", canonical), session, "canonical references match even when route and physical path differ");
assert.equal(selectRuntimeSession(frame, "a", { ...canonical, session: { hostId: "local", sessionId: "other" }, sessionPath: session.sessionPath }), undefined, "matching legacy paths cannot override distinct canonical identities");
session.remote = true;
session.hostId = "remote-host";
assert.equal(selectRuntimeSession(frame, "a", canonical), undefined, "the same session ID on another host is not the same session");
assert.equal(selectRuntimeSession(frame, "a", { ...canonical, session: { ...canonical.session, hostId: "remote-host" } }), session);
assert.equal(selectRuntimeSession(frame, "a", session.sessionPath), session, "remote path callers keep their scoped selection");
}
const rawStore = createRuntimeStateStore();
const store = { ...rawStore, accept: (next: RuntimeProjection, authoritative = false) => acceptRuntimeState(rawStore, next, authoritative) };
let updates = 0;
@@ -8,6 +8,7 @@ import { continueDelivery } from "../lib/deliveryContinue";
import type { WireEvent } from "../lib/types";
import { submitPlanDecision, type SessionActionPorts } from "../app-runtime/sessionActionOwner";
import { createSessionSurfaceFence } from "../app-runtime/sessionTarget";
import { sessionIdentityStableKey } from "../lib/sessionIdentity";
let passed = 0;
let failed = 0;
@@ -91,7 +92,10 @@ eq(notice.kind === "notice" && notice.level, "warn", "the notice is a warning");
eq(failedState.running, false, "send_failed stops the running indicator");
eq(failedState.pendingUser, undefined, "send_failed clears the pending marker");
const waitingAsk = reducer({ ...initialState }, {
const promptMeta = { label: "", ready: true, eventChannel: "agent:event", cwd: "", session: { hostId: "local", sessionId: "session-a" }, sessionGeneration: 1 };
const askTarget = (promptId: string, turnId: string) => ({ tabId: "tab-a", sessionKey: sessionIdentityStableKey(promptMeta), hostId: "local", sessionId: "session-a", sessionGeneration: 1,
promptId, turnId, kind: "ask" as const, instanceKey: `${turnId}:${promptId}` });
const waitingAsk = reducer({ ...initialState, meta: promptMeta }, {
type: "event",
e: {
kind: "ask_request",
@@ -133,20 +137,20 @@ const reconciledIdle = reducer(rejectedCollision, {
eq(reconciledIdle.running, false, "authoritative idle snapshot releases the rejected submit gate");
eq(reconciledIdle.ask, undefined, "authoritative idle snapshot clears a stale Ask");
const answeredAsk = reducer(waitingAsk, { type: "ask_submit_succeeded", id: "ask-existing", epoch: waitingAsk.promptEpoch });
const answeredAsk = reducer(waitingAsk, { type: "ask_submit_succeeded", target: askTarget("ask-existing", "turn-existing"), epoch: waitingAsk.promptEpoch });
eq(answeredAsk.ask, undefined, "successful Ask submission clears the matching prompt");
eq(answeredAsk.resolvedPromptId, "ask-existing", "successful Ask submission tombstones the matching prompt id");
const nextAsk = reducer(waitingAsk, {
type: "event",
e: { kind: "ask_request", turnId: "turn-existing", ask: { id: "ask-next", questions: [] } } as WireEvent,
});
const lateAskSuccess = reducer(nextAsk, { type: "ask_submit_succeeded", id: "ask-existing", epoch: nextAsk.promptEpoch });
const lateAskSuccess = reducer(nextAsk, { type: "ask_submit_succeeded", target: askTarget("ask-existing", "turn-existing"), epoch: nextAsk.promptEpoch });
eq(lateAskSuccess.ask?.id, "ask-next", "late Ask success cannot clear a newer prompt");
const rebuiltAsk = reducer(reducer(waitingAsk, { type: "controller_rebuilt" }), {
type: "event",
e: { kind: "ask_request", turnId: "turn-new", ask: { id: "ask-existing", questions: [] } } as WireEvent,
});
const oldEpochSuccess = reducer(rebuiltAsk, { type: "ask_submit_succeeded", id: "ask-existing", epoch: waitingAsk.promptEpoch });
const oldEpochSuccess = reducer(rebuiltAsk, { type: "ask_submit_succeeded", target: askTarget("ask-existing", "turn-existing"), epoch: waitingAsk.promptEpoch });
eq(oldEpochSuccess.ask?.id, "ask-existing", "old prompt epoch cannot clear an id reused by a rebuilt controller");
const readinessStarted = reducer(sent, { type: "event", e: { kind: "turn_started" } as WireEvent });
@@ -276,7 +280,7 @@ eq(controllerSource.includes('e.kind === "mcp_surface_ready"'), true, "reducer h
const calls: string[] = [];
const ports: SessionActionPorts = {
approveForTab: () => undefined,
resolvePlanForTab: (tabId, id, action) => calls.push(`resolve:${tabId}:${id}:${action}`),
resolvePlanForTab: (target, action) => { calls.push(`resolve:${target.tabId}:${target.promptId}:${action}`); },
resolveRecoveryForTab: () => undefined,
answerQuestionForTab: async () => undefined,
answerMCPForTab: () => undefined,
@@ -287,7 +291,8 @@ eq(controllerSource.includes('e.kind === "mcp_surface_ready"'), true, "reducer h
notePlanMode: (tabId, enabled) => calls.push(`plan:${tabId}:${enabled}`),
drainRemoteApprovals: () => undefined,
};
const target = { tabId: "tab-source", sessionKey: "session-source:1", promptId: "approval-7" };
const target = { tabId: "tab-source", sessionKey: "session-source:1", hostId: "local", sessionId: "session-source", sessionGeneration: 1,
promptId: "approval-7", kind: "plan" as const, instanceKey: "plan-source:approval-7" };
await submitPlanDecision(target, {
action: "start_execution", leavePlanMode: true, remote: false, goal: "", toolApprovalMode: "ask",
}, ports, { checkpoint() {}, ownsUI: () => true });
@@ -15,12 +15,12 @@ let gate = deferred();
let prompt = "approval-A";
const calls: string[] = [];
const ports: PromptPorts = {
isPromptCurrentForTab: (tab, _kind, id) => tab === "A" && id === prompt,
approveForTab: tab => { calls.push(`approve:${tab}`); },
resolvePlanForTab: (tab, id) => { calls.push(`resolve:${tab}:${id}`); },
resolveRecoveryForTab: tab => { calls.push(`recover:${tab}`); },
answerQuestionForTab: async tab => { calls.push(`question:${tab}`); },
answerMCPForTab: tab => { calls.push(`mcp:${tab}`); },
isPromptCurrentForTab: target => target.tabId === "A" && target.promptId === prompt,
approveForTab: target => { calls.push(`approve:${target.tabId}`); },
resolvePlanForTab: target => { calls.push(`resolve:${target.tabId}:${target.promptId}`); },
resolveRecoveryForTab: target => { calls.push(`recover:${target.tabId}`); },
answerQuestionForTab: async target => { calls.push(`question:${target.tabId}`); },
answerMCPForTab: target => { calls.push(`mcp:${target.tabId}`); },
setCollaborationModeForTab: async tab => { calls.push(`mode:${tab}`); },
clearGoalForTab: async tab => { calls.push(`clear:${tab}`); entered.resolve(); await gate.promise; },
setRemoteComposerProfile: async tab => { calls.push(`remote:${tab}`); entered.resolve(); await gate.promise; return [prompt]; },
@@ -33,7 +33,7 @@ let commands!: ReturnType<typeof useSessionPromptCommands>;
function Probe({ tab, generation = "1", remote = false }: { tab: string; generation?: string; remote?: boolean }) {
const target = { tabId: tab, sessionKey: tab + generation };
const operations = useSessionOperations({ visible: target, resources: ["A", "B"].map(tabId => ({ tabId, sessionKey: tabId + generation })) });
commands = useSessionPromptCommands({ target, approval: { id: prompt, tool: "exit_plan_mode" }, questionId: prompt,
commands = useSessionPromptCommands({ target, session: { hostId: "local", sessionId: `session-${tab}` }, sessionGeneration: Number(generation), approval: { id: prompt, tool: "exit_plan_mode", subject: "plan", turnId: `turn-${tab}`, runtimeEpoch: `runtime-${tab}` },
remote, goal: "fixture", toolApprovalMode: "ask", ports, operations, reportError: error => { throw error; } });
return null;
}
@@ -396,8 +396,12 @@ eq(sameMeta(meta({ collaborationMode: "normal" }), meta({ collaborationMode: "pl
eq(preserved.toolApprovalMode, "workspace-write", "blank tab snapshot migrates legacy auto to workspace write");
eq(preserved.autoApproveTools, false, "blank tab snapshot does not silently enable full access");
const todos = [{ content: "Keep task state", status: "in_progress" }];
const withTodos = metaFromTab(tab(), meta({ canonicalTodos: todos }));
const previous = meta({ sessionPath: "/sessions/a", sessionGeneration: 1, canonicalTodos: todos });
const withTodos = metaFromTab(tab({ sessionPath: "/sessions/a", sessionGeneration: 1 }), previous);
eq(withTodos.canonicalTodos, todos, "optimistic tab metadata preserves canonical todos for the same session");
eq(metaFromTab(tab({ sessionPath: "/sessions/b" }), previous).canonicalTodos, undefined, "reused tab metadata cannot carry A's todos into B");
eq(metaFromTab(tab({ sessionPath: "" }), previous).canonicalTodos, undefined, "a blank session cannot inherit the previous todo batch");
eq(metaFromTab(tab({ sessionPath: "/sessions/a", sessionGeneration: 2 }), previous).canonicalTodos, undefined, "reopening the same path cannot inherit a previous binding generation's todos");
const canonical = metaFromTab(tab({ sessionId: "canonical", session: { hostId: "local", sessionId: "canonical" } }));
eq(canonical.session?.sessionId, "canonical", "optimistic tab metadata carries canonical SessionRef identity");
}
@@ -1,11 +1,9 @@
import type { CollaborationMode, QuestionAnswer, ToolApprovalMode } from "../lib/types";
import type { InteractionTarget } from "../lib/interactionTarget";
export { interactionInstanceKey as sessionPromptInstanceKey } from "../lib/interactionTarget";
import type { SessionOperationAuthority } from "./useSessionOperations";
export type SessionPromptTarget = Readonly<{
tabId: string;
sessionKey: string;
promptId: string;
}>;
export type SessionPromptTarget = InteractionTarget;
export type PlanDecisionAction = "start_execution" | "revise_plan" | "exit_plan";
export type RecoveryAction = "continue" | "continue_task" | "revise" | "stop";
@@ -13,11 +11,11 @@ export type MCPInteractionAction = "accept" | "decline" | "cancel";
export type SessionActionPorts = {
approveForTab: (tabId: string, id: string, allow: boolean, session: boolean, persist: boolean) => void;
resolvePlanForTab: (tabId: string, id: string, action: PlanDecisionAction) => void;
resolveRecoveryForTab: (tabId: string, id: string, action: RecoveryAction, feedback: string) => void;
answerQuestionForTab: (tabId: string, id: string, answers: QuestionAnswer[]) => Promise<void>;
answerMCPForTab: (tabId: string, id: string, action: MCPInteractionAction, content?: Record<string, unknown>) => void;
approveForTab: (target: SessionPromptTarget, allow: boolean, session: boolean, persist: boolean) => void;
resolvePlanForTab: (target: SessionPromptTarget, action: PlanDecisionAction) => void;
resolveRecoveryForTab: (target: SessionPromptTarget, action: RecoveryAction, feedback: string) => void;
answerQuestionForTab: (target: SessionPromptTarget, answers: QuestionAnswer[]) => Promise<void>;
answerMCPForTab: (target: SessionPromptTarget, action: MCPInteractionAction, content?: Record<string, unknown>) => void;
setCollaborationModeForTab: (tabId: string, mode: CollaborationMode) => Promise<void>;
clearGoalForTab: (tabId: string) => Promise<void>;
setRemoteComposerProfile: (
@@ -36,7 +34,7 @@ export function submitApproval(
input: { allow: boolean; session: boolean; persist: boolean },
ports: Pick<SessionActionPorts, "approveForTab">,
): void {
ports.approveForTab(target.tabId, target.promptId, input.allow, input.session, input.persist);
ports.approveForTab(target, input.allow, input.session, input.persist);
}
export async function submitPlanDecision(
@@ -69,7 +67,7 @@ export async function submitPlanDecision(
ports.patchComposerProfile(target.tabId, "normal");
}
authority.checkpoint();
ports.resolvePlanForTab(target.tabId, target.promptId, input.action);
ports.resolvePlanForTab(target, input.action);
}
export function submitRecovery(
@@ -78,7 +76,7 @@ export function submitRecovery(
feedback: string,
ports: Pick<SessionActionPorts, "resolveRecoveryForTab">,
): void {
ports.resolveRecoveryForTab(target.tabId, target.promptId, action, feedback);
ports.resolveRecoveryForTab(target, action, feedback);
}
export function submitQuestion(
@@ -86,7 +84,7 @@ export function submitQuestion(
answers: QuestionAnswer[],
ports: Pick<SessionActionPorts, "answerQuestionForTab">,
): Promise<void> {
return ports.answerQuestionForTab(target.tabId, target.promptId, answers);
return ports.answerQuestionForTab(target, answers);
}
export function submitMCPInteraction(
@@ -95,5 +93,5 @@ export function submitMCPInteraction(
content: Record<string, unknown> | undefined,
ports: Pick<SessionActionPorts, "answerMCPForTab">,
): void {
ports.answerMCPForTab(target.tabId, target.promptId, action, content);
ports.answerMCPForTab(target, action, content);
}
@@ -11,7 +11,7 @@ export type PromptRequest =
| { kind: "question"; answers: QuestionAnswer[] }
| { kind: "mcp"; action: MCPInteractionAction; content?: Record<string, unknown> };
export type PromptPorts = SessionActionPorts & {
isPromptCurrentForTab: (tabId: string, kind: SessionPromptKind, promptId: string) => boolean;
isPromptCurrentForTab: (target: SessionPromptTarget) => boolean;
rememberRevision: (tabId: string, revision: string) => void;
};
export type PromptInput = { target: SessionPromptTarget; promptKind: SessionPromptKind; request: PromptRequest; ports: PromptPorts };
@@ -22,7 +22,7 @@ export async function executeSessionPrompt(input: PromptInput, source: SessionOp
const authority: SessionOperationAuthority = {
checkpoint() {
source.checkpoint();
if (!ports.isPromptCurrentForTab(target.tabId, input.promptKind, target.promptId)) throw new CommandCancelled("superseded");
if (!ports.isPromptCurrentForTab(target)) throw new CommandCancelled("superseded");
},
ownsUI: () => source.ownsUI(),
};
@@ -364,6 +364,9 @@ export function useAppSessionComposition(input: AppSessionCompositionInput) {
// be reached is gone either way.
const extensionSurface = useExtensionSurface({
activeTabId,
hostId: state.meta?.session?.hostId,
sessionId: state.meta?.sessionId,
sessionGeneration: state.meta?.sessionGeneration,
form: state.extensionForm,
notifications: state.extensionNotifications,
dismissForm: dismissExtensionForm,
@@ -460,8 +463,12 @@ export function useAppSessionComposition(input: AppSessionCompositionInput) {
});
const promptCommands = useSessionPromptCommands({
target: { tabId: activeTabId ?? "", sessionKey: activeSessionIdentity },
approval: state.approval ? { id: state.approval.id, tool: state.approval.tool } : undefined,
questionId: state.ask?.id, remote: Boolean(activeTab?.remote), goal, toolApprovalMode,
session: state.meta?.session,
sessionGeneration: state.meta?.sessionGeneration,
approval: state.approval,
question: state.ask,
mcpInteraction: state.mcpInteraction,
remote: Boolean(activeTab?.remote), goal, toolApprovalMode,
operations: sessionOperations,
ports: {
approveForTab, isPromptCurrentForTab, resolvePlanForTab: resolvePlanDecisionForTab,
@@ -1,8 +1,15 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { app } from "../lib/bridge";
import { useCommittedCommand } from "../lib/useCommittedCommand";
export type ExtensionSurfaceView = { pluginId: string; surfaceId: string };
export type ExtensionSurfaceView = {
pluginId: string;
surfaceId: string;
sessionId?: string;
generation?: number;
formInstanceId: string;
formInstanceExact: boolean;
};
export type ExtensionNotificationView = { severity?: string; title: string; body?: string };
/**
@@ -13,14 +20,21 @@ export type ExtensionNotificationView = { severity?: string; title: string; body
*/
export function useExtensionSurface(input: {
activeTabId: string | undefined;
hostId?: string;
sessionId?: string;
sessionGeneration?: number;
form: ExtensionSurfaceView | undefined;
notifications: readonly ExtensionNotificationView[] | undefined;
dismissForm(): void;
dismissForm(tabId?: string, identity?: Pick<ExtensionSurfaceView, "pluginId" | "surfaceId" | "formInstanceId">): void;
drainNotifications(): void;
showToast(message: string, level: "info" | "warn" | "error"): void;
}) {
const { activeTabId, form, notifications, dismissForm, drainNotifications, showToast } = input;
const [extensionFormBusy, setExtensionFormBusy] = useState(false);
const formKey = form ? JSON.stringify([activeTabId ?? "", form.pluginId, form.surfaceId, form.formInstanceId]) : "";
const visibleFormKeyRef = useRef(formKey);
visibleFormKeyRef.current = formKey;
const [busyFormKey, setBusyFormKey] = useState("");
const extensionFormBusy = Boolean(formKey && busyFormKey === formKey);
useEffect(() => {
const pending = notifications;
@@ -34,29 +48,56 @@ export function useExtensionSurface(input: {
const submitExtensionForm = useCommittedCommand(async (values: Record<string, unknown>) => {
const pending = form;
if (!pending || !activeTabId || extensionFormBusy) return;
setExtensionFormBusy(true);
if (!pending || !activeTabId || busyFormKey === formKey) return;
const target = {
tabId: activeTabId,
hostId: input.hostId ?? "local",
sessionId: pending.sessionId ?? input.sessionId ?? "",
sessionGeneration: input.sessionGeneration ?? 0,
pluginId: pending.pluginId,
surfaceId: pending.surfaceId,
pluginGeneration: pending.generation ?? 0,
formInstanceId: pending.formInstanceId,
};
const identity = { pluginId: pending.pluginId, surfaceId: pending.surfaceId, formInstanceId: pending.formInstanceId };
const requestKey = formKey;
setBusyFormKey(requestKey);
try {
await app.SubmitExtensionForm(activeTabId, pending.pluginId, pending.surfaceId, values);
dismissForm();
if (!app.SubmitExtensionFormExact || !pending.formInstanceExact || !target.sessionId || !target.sessionGeneration || !target.pluginGeneration || !target.formInstanceId) {
throw new Error("Exact extension form submission is unavailable; refresh or upgrade Reasonix.");
}
await app.SubmitExtensionFormExact(target, values);
dismissForm(target.tabId, identity);
} catch (err) {
showToast(err instanceof Error ? err.message : String(err), "error");
if (visibleFormKeyRef.current === requestKey) showToast(err instanceof Error ? err.message : String(err), "error");
} finally {
setExtensionFormBusy(false);
setBusyFormKey((current) => current === requestKey ? "" : current);
}
});
const cancelExtensionForm = useCommittedCommand(async () => {
const pending = form;
if (!pending || extensionFormBusy) return;
setExtensionFormBusy(true);
if (!pending || busyFormKey === formKey) return;
const target = {
tabId: activeTabId ?? "",
hostId: input.hostId ?? "local",
sessionId: pending.sessionId ?? input.sessionId ?? "",
sessionGeneration: input.sessionGeneration ?? 0,
pluginId: pending.pluginId,
surfaceId: pending.surfaceId,
pluginGeneration: pending.generation ?? 0,
formInstanceId: pending.formInstanceId,
};
const identity = { pluginId: pending.pluginId, surfaceId: pending.surfaceId, formInstanceId: pending.formInstanceId };
const requestKey = formKey;
setBusyFormKey(requestKey);
try {
if (activeTabId) {
await app.SubmitExtensionForm(activeTabId, pending.pluginId, pending.surfaceId, { cancelled: true }).catch(() => {});
if (app.SubmitExtensionFormExact && pending.formInstanceExact && target.tabId && target.sessionId && target.sessionGeneration && target.pluginGeneration && target.formInstanceId) {
await app.SubmitExtensionFormExact(target, { cancelled: true }).catch(() => {});
}
dismissForm();
dismissForm(target.tabId, identity);
} finally {
setExtensionFormBusy(false);
setBusyFormKey((current) => current === requestKey ? "" : current);
}
});
@@ -1,5 +1,4 @@
import { useMemo } from "react";
import { AlarmClock, Activity, BarChart3, Brain, Cpu, Palette, Puzzle, RotateCw, Server, Settings as SettingsIcon, SquarePen, TerminalSquare, Trash2 } from "lucide-react";
import { app } from "../lib/bridge";
import { useCommittedCommand } from "../lib/useCommittedCommand";
import { useGlobalShortcut } from "../lib/keyboardShortcuts";
@@ -85,16 +84,16 @@ export function usePaletteCommands(input: PaletteCommandsInput) {
const paletteItems = useMemo<PaletteItem[]>(() => {
const navigation = useAppNavigationStore.getState();
const cmds: PaletteItem[] = [
{ id: "cmd-new", group: t("palette.group.commands"), title: t("palette.cmd.newSession"), icon: <SquarePen size={15} />, compact: true, keywords: ["new", "新建"], run: () => void ports.handleNewTab() },
{ id: "cmd-automation", group: t("palette.group.commands"), title: t("sidebar.automation"), icon: <AlarmClock size={15} />, compact: true, keywords: ["automation", "自动化"], run: () => navigation.openPage({ kind: "automation" }) },
{ id: "cmd-trash", group: t("palette.group.commands"), title: t("palette.cmd.trash"), icon: <Trash2 size={15} />, compact: true, keywords: ["trash", "回收站"], run: () => void ports.openTrash() },
{ id: "cmd-settings", group: t("palette.group.commands"), title: t("palette.cmd.settings"), icon: <SettingsIcon size={15} />, compact: true, keywords: ["settings", "设置"], run: () => navigation.setSettingsTarget(navigation.lastSettingsTarget) },
{ id: "cmd-appearance", group: t("palette.group.commands"), title: t("palette.cmd.appearance"), icon: <Palette size={15} />, compact: true, keywords: ["theme", "appearance", "外观", "主题"], run: () => navigation.setSettingsTarget("appearance") },
{ id: "cmd-new", group: t("palette.group.commands"), title: t("palette.cmd.newSession"), icon: "new", compact: true, keywords: ["new", "新建"], run: () => void ports.handleNewTab() },
{ id: "cmd-automation", group: t("palette.group.commands"), title: t("sidebar.automation"), icon: "automation", compact: true, keywords: ["automation", "自动化"], run: () => navigation.openPage({ kind: "automation" }) },
{ id: "cmd-trash", group: t("palette.group.commands"), title: t("palette.cmd.trash"), icon: "trash", compact: true, keywords: ["trash", "回收站"], run: () => void ports.openTrash() },
{ id: "cmd-settings", group: t("palette.group.commands"), title: t("palette.cmd.settings"), icon: "settings", compact: true, keywords: ["settings", "设置"], run: () => navigation.setSettingsTarget(navigation.lastSettingsTarget) },
{ id: "cmd-appearance", group: t("palette.group.commands"), title: t("palette.cmd.appearance"), icon: "appearance", compact: true, keywords: ["theme", "appearance", "外观", "主题"], run: () => navigation.setSettingsTarget("appearance") },
{
id: "cmd-theme-reset",
group: t("palette.group.commands"),
title: t("settings.themeLibrary.reset"),
icon: <Palette size={15} />,
icon: "appearance",
compact: true,
keywords: ["theme", "reset", "default", "恢复默认", "主题"],
run: () => {
@@ -106,13 +105,13 @@ export function usePaletteCommands(input: PaletteCommandsInput) {
.catch((err) => showToast(err instanceof Error ? err.message : String(err), "error"));
},
},
{ id: "cmd-memory", group: t("palette.group.commands"), title: t("palette.cmd.memory"), icon: <Brain size={15} />, compact: true, keywords: ["memory", "记忆"], run: () => navigation.setSettingsTarget("memory") },
{ id: "cmd-models", group: t("palette.group.commands"), title: t("palette.cmd.models"), icon: <Cpu size={15} />, compact: true, keywords: ["model", "模型"], run: () => navigation.setSettingsTarget("models") },
{ id: "cmd-memory", group: t("palette.group.commands"), title: t("palette.cmd.memory"), icon: "memory", compact: true, keywords: ["memory", "记忆"], run: () => navigation.setSettingsTarget("memory") },
{ id: "cmd-models", group: t("palette.group.commands"), title: t("palette.cmd.models"), icon: "models", compact: true, keywords: ["model", "模型"], run: () => navigation.setSettingsTarget("models") },
{
id: "cmd-usage-stats",
group: t("palette.group.commands"),
title: t("palette.cmd.usageStats"),
icon: <BarChart3 size={15} />,
icon: "usage",
compact: true,
keywords: ["usage", "stats", "statistics", "用量", "统计"],
run: () => {
@@ -123,13 +122,13 @@ export function usePaletteCommands(input: PaletteCommandsInput) {
navigation.setSettingsTarget("models");
},
},
{ id: "cmd-task-center", group: t("palette.group.commands"), title: t("palette.cmd.taskCenter"), icon: <Activity size={15} />, compact: true, keywords: ["task", "tasks", "center", "任务", "任务中心"], run: () => ports.setTasksOpen("all") },
{ id: "cmd-terminal", group: t("palette.group.commands"), title: t("rightDock.terminal"), icon: <TerminalSquare size={15} />, compact: true, keywords: ["terminal", "shell", "终端"], run: () => ports.toggleTerminalPanel() },
{ id: "cmd-task-center", group: t("palette.group.commands"), title: t("palette.cmd.taskCenter"), icon: "tasks", compact: true, keywords: ["task", "tasks", "center", "任务", "任务中心"], run: () => ports.setTasksOpen("all") },
{ id: "cmd-terminal", group: t("palette.group.commands"), title: t("rightDock.terminal"), icon: "terminal", compact: true, keywords: ["terminal", "shell", "终端"], run: () => ports.toggleTerminalPanel() },
{
id: "cmd-reload-runtime",
group: t("palette.group.commands"),
title: t("palette.cmd.reloadRuntime"),
icon: <RotateCw size={15} />,
icon: "reload",
compact: true,
keywords: ["reload", "runtime", "重载", "运行时"],
run: () => {
@@ -168,7 +167,7 @@ export function usePaletteCommands(input: PaletteCommandsInput) {
? t("palette.remote.open", { host: host.label })
: t("palette.remote.connect", { host: host.label }),
hint: host.defaultWorkspace || target,
icon: <Server size={15} />,
icon: "remote",
keywords: ["ssh", "remote", "远程", "连接", host.label, host.host],
run: () => {
if (connected) ports.openRemoteWorkspaceFromStatus(host);
@@ -181,7 +180,7 @@ export function usePaletteCommands(input: PaletteCommandsInput) {
group: t("palette.group.extensions"),
title: action.description || action.slash,
hint: action.slash,
icon: <Puzzle size={15} />,
icon: "extension",
keywords: ["extension", "扩展", action.plugin, action.action, action.slash],
run: () => {
const tabID = activeTabId;
@@ -1,13 +1,19 @@
import { useCallback, useMemo } from "react";
import { useCommittedCommand } from "../lib/useCommittedCommand";
import type { QuestionAnswer, ToolApprovalMode } from "../lib/types";
import type { QuestionAnswer, ToolApprovalMode, WireApproval, WireAsk, WireMCPInteraction } from "../lib/types";
import { executeSessionPrompt, type PromptPorts, type PromptRequest, type SessionPromptKind } from "./sessionPromptExecutor";
import { interactionInstanceKey, type InteractionKind, type InteractionTarget } from "../lib/interactionTarget";
import type { SessionRef } from "../lib/sessionRef";
import type { MCPInteractionAction, RecoveryAction } from "./sessionActionOwner";
import type { SessionResource, useSessionOperations } from "./useSessionOperations";
type Input = {
target: SessionResource;
approval?: { id: string; tool: string };
questionId?: string;
sessionGeneration?: number;
session?: SessionRef | null;
approval?: WireApproval;
question?: WireAsk;
mcpInteraction?: WireMCPInteraction;
remote: boolean;
goal: string;
toolApprovalMode: ToolApprovalMode;
@@ -17,31 +23,67 @@ type Input = {
};
export function useSessionPromptCommands(input: Input) {
const run = useCommittedCommand(async (promptId: string | undefined, promptKind: SessionPromptKind, request: PromptRequest) => {
if (!promptId || !input.target.tabId) return;
const target = { ...input.target, promptId };
const result = await input.operations(target, `prompt:${promptKind}`, { target, promptKind, request, ports: input.ports }, executeSessionPrompt);
const { target: sessionTarget, session, sessionGeneration, operations, ports } = input;
const makeTarget = useCallback((prompt: WireApproval | WireAsk | WireMCPInteraction | undefined, promptKind: SessionPromptKind): InteractionTarget | undefined => {
if (!prompt?.id || !sessionTarget.tabId || !session?.sessionId || !sessionGeneration) return undefined;
let kind: InteractionKind;
if (promptKind === "mcpInteraction") kind = "mcp";
else if (promptKind === "ask") kind = "ask";
else {
const approval = prompt as WireApproval;
kind = approval.kind === "recovery" || approval.recovery
? "recovery" : approval.tool === "exit_plan_mode" ? "plan" : "approval";
}
const base = {
...sessionTarget,
hostId: session.hostId,
sessionId: session.sessionId,
sessionGeneration,
promptId: prompt.id,
turnId: prompt.turnId,
runtimeEpoch: prompt.runtimeEpoch,
kind,
requestGeneration: "generation" in prompt ? prompt.generation : undefined,
permissionRevision: "permissionRevision" in prompt ? prompt.permissionRevision : undefined,
};
return { ...base, instanceKey: interactionInstanceKey(base) };
}, [sessionTarget, session, sessionGeneration]);
const approvalTarget = makeTarget(input.approval, "approval");
const questionTarget = makeTarget(input.question, "ask");
const mcpTarget = makeTarget(input.mcpInteraction, "mcpInteraction");
const run = useCallback(async (target: InteractionTarget | undefined, promptKind: SessionPromptKind, request: PromptRequest) => {
if (!target) return;
const result = await operations(
{ tabId: target.tabId, sessionKey: target.sessionKey },
`prompt:${promptKind}`,
{ target, promptKind, request, ports },
executeSessionPrompt,
);
if (result.status === "failed") throw result.error;
});
const plan = useCommittedCommand((action: "start_execution" | "revise_plan" | "exit_plan", revision?: string) => run(input.approval?.id, "approval", {
}, [operations, ports]);
const plan = useCallback((action: "start_execution" | "revise_plan" | "exit_plan", revision?: string) => run(approvalTarget, "approval", {
kind: "plan", action, leavePlanMode: action !== "revise_plan", remote: input.remote,
goal: input.goal, toolApprovalMode: input.toolApprovalMode, revision,
}));
}), [approvalTarget, input.remote, input.goal, input.toolApprovalMode, run]);
const report = useCommittedCommand(input.reportError);
const handleApprovalAnswer = useCommittedCommand((allow: boolean, session: boolean, persist: boolean) => (
const handleApprovalAnswer = useCallback((allow: boolean, session: boolean, persist: boolean) => (
input.approval?.tool === "exit_plan_mode"
? plan(allow ? "start_execution" : "revise_plan")
: run(input.approval?.id, "approval", { kind: "approval", allow, session, persist })
));
const handleRecoveryAnswer = useCommittedCommand((action: RecoveryAction, feedback = "") => {
void run(input.approval?.id, "approval", { kind: "recovery", action, feedback }).catch(report);
});
const handleRevisePlan = useCommittedCommand((revision: string) => { void plan("revise_plan", revision).catch(report); });
const handleExitPlan = useCommittedCommand(() => plan("exit_plan"));
const handleQuestionAnswer = useCommittedCommand((id: string, answers: QuestionAnswer[]) => run(id, "ask", { kind: "question", answers }));
const handleQuestionDismiss = useCommittedCommand(() => run(input.questionId, "ask", { kind: "question", answers: [] }));
const handleMCPAnswer = useCommittedCommand((id: string, action: MCPInteractionAction, content?: Record<string, unknown>) => {
void run(id, "mcpInteraction", { kind: "mcp", action, content }).catch(report);
});
return { handleApprovalAnswer, handleRecoveryAnswer, handleRevisePlan, handleExitPlan, handleQuestionAnswer, handleQuestionDismiss, handleMCPAnswer };
: run(approvalTarget, "approval", { kind: "approval", allow, session, persist })
), [approvalTarget, input.approval?.tool, plan, run]);
const handleRecoveryAnswer = useCallback((action: RecoveryAction, feedback = "") => {
void run(approvalTarget, "approval", { kind: "recovery", action, feedback }).catch(report);
}, [approvalTarget, report, run]);
const handleRevisePlan = useCallback((revision: string) => { void plan("revise_plan", revision).catch(report); }, [plan, report]);
const handleExitPlan = useCallback(() => plan("exit_plan"), [plan]);
const handleQuestionAnswer = useCallback((_id: string, answers: QuestionAnswer[]) => run(questionTarget, "ask", { kind: "question", answers }), [questionTarget, run]);
const handleQuestionDismiss = useCallback(() => run(questionTarget, "ask", { kind: "question", answers: [] }), [questionTarget, run]);
const handleMCPAnswer = useCallback((_id: string, action: MCPInteractionAction, content?: Record<string, unknown>) => {
void run(mcpTarget, "mcpInteraction", { kind: "mcp", action, content }).catch(report);
}, [mcpTarget, report, run]);
return useMemo(() => ({
approvalTarget, questionTarget, mcpTarget,
handleApprovalAnswer, handleRecoveryAnswer, handleRevisePlan, handleExitPlan,
handleQuestionAnswer, handleQuestionDismiss, handleMCPAnswer,
}), [approvalTarget, questionTarget, mcpTarget, handleApprovalAnswer, handleRecoveryAnswer, handleRevisePlan, handleExitPlan, handleQuestionAnswer, handleQuestionDismiss, handleMCPAnswer]);
}
@@ -113,7 +113,7 @@ export function buildDecisionFooterSurface(input: DecisionFooterSurfaceInput): D
if ((surface === "tool_approval" || surface === "plan_approval") && view.approval) {
return {
kind: "approval",
identity: `${activeTabId ?? ""}:${view.approval.id}`,
identity: prompts.approvalTarget?.instanceKey ?? `${activeTabId ?? ""}:${view.approval.id}`,
props: {
approval: view.approval,
cwd: view.cwd,
@@ -133,9 +133,10 @@ export function buildDecisionFooterSurface(input: DecisionFooterSurfaceInput): D
if (surface === "ask" && view.ask) {
return {
kind: "ask",
identity: `${activeTabId ?? ""}:${view.ask.id}`,
identity: prompts.questionTarget?.instanceKey ?? `${activeTabId ?? ""}:${view.ask.id}`,
props: {
ask: view.ask,
draftScope: prompts.questionTarget?.instanceKey ?? `${activeTabId ?? ""}:${view.ask.id}`,
onAnswer: prompts.handleQuestionAnswer,
onDismiss: prompts.handleQuestionDismiss,
onStop: input.onStop,
@@ -145,9 +146,10 @@ export function buildDecisionFooterSurface(input: DecisionFooterSurfaceInput): D
if (surface === "mcp_interaction" && view.mcpInteraction) {
return {
kind: "mcp",
identity: `${activeTabId ?? ""}:${view.mcpInteraction.id}`,
identity: prompts.mcpTarget?.instanceKey ?? `${activeTabId ?? ""}:${view.mcpInteraction.id}`,
props: {
interaction: view.mcpInteraction,
instanceKey: prompts.mcpTarget?.instanceKey ?? `${activeTabId ?? ""}:${view.mcpInteraction.id}`,
busy: false,
onAnswer: prompts.handleMCPAnswer,
onOpenLink: input.onOpenLink,
@@ -157,7 +159,7 @@ export function buildDecisionFooterSurface(input: DecisionFooterSurfaceInput): D
if (surface === "extension_form" && view.extensionForm) {
return {
kind: "extension",
identity: `${activeTabId ?? ""}:${view.extensionForm.pluginId}:${view.extensionForm.surfaceId}`,
identity: `${activeTabId ?? ""}:${view.extensionForm.pluginId}:${view.extensionForm.surfaceId}:${view.extensionForm.formInstanceId}`,
props: {
surface: view.extensionForm,
busy: extension.extensionFormBusy,
@@ -298,20 +298,32 @@ function InteractiveApprovalModal({
const closingRef = useRef(false);
const fileMenu = useFileReferenceMenu(revisionText, cwd, tabId, workspaceScopeKey);
const answerWithExit = (fn: () => void) => {
const answerWithExit = (fn: () => void | Promise<void>) => {
if (closingRef.current || submitting) return;
closingRef.current = true;
setSubmitting(true);
let result: void | Promise<void>;
try {
// The decision belongs to this mounted request. Start it before the
// cosmetic animation so a later tab switch cannot retarget the action.
result = fn();
} catch {
closingRef.current = false;
setSubmitting(false);
return;
}
void Promise.resolve(result).catch(() => {
closingRef.current = false;
setSubmitting(false);
});
const el = shelfRef.current;
if (el) {
animateElementExit(el, {
opacity: 0,
y: 8,
duration: DUR_FAST,
onComplete: fn,
onComplete: () => undefined,
});
} else {
fn();
}
};
@@ -320,10 +332,9 @@ function InteractiveApprovalModal({
const resolve = onResolveRecovery ?? ((a: "continue" | "continue_task" | "revise") => onAnswer(a !== "revise", false, false));
if (action === "revise") {
const text = feedback?.trim().slice(0, RECOVERY_FEEDBACK_MAX) ?? "";
resolve("revise", text || undefined);
return;
return resolve("revise", text || undefined);
}
resolve(action);
return resolve(action);
},
[onResolveRecovery, onAnswer],
);
+7 -6
View File
@@ -2,6 +2,7 @@ import { useEffect, useId, useMemo, useRef, useState } from "react";
import { useT } from "../lib/i18n";
import { ChevronDown, ChevronLeft, ChevronRight, ChevronUp, X } from "lucide-react";
import type { QuestionAnswer, WireAsk, WireAskQuestion } from "../lib/types";
import { createInteractionDraftStore } from "../lib/interactionDraftStore";
import {
DecisionConfirmBar,
PromptAction,
@@ -10,9 +11,8 @@ import {
PromptShelf,
} from "./PromptShelf";
const askDrafts = new Map<string, AskDraft>();
const askDraftKey = (scope: string, ask: WireAsk) =>
JSON.stringify([scope, ask.runtimeEpoch ?? "", ask.turnId ?? "", ask.id]);
`${scope}\u0000${JSON.stringify([ask.runtimeEpoch ?? "", ask.turnId ?? "", ask.id])}`;
type AnswerMode = "option" | "custom";
type AskDraft = {
sel: Record<string, string[]>;
@@ -21,9 +21,10 @@ type AskDraft = {
active: number;
selectedIndex: number;
};
const askDrafts = createInteractionDraftStore<AskDraft>();
function readAskDraft(key: string): AskDraft | undefined {
const draft = askDrafts.get(key);
const draft = askDrafts.read(key);
return draft ? { ...draft, sel: { ...draft.sel }, custom: { ...draft.custom }, answerMode: { ...draft.answerMode } } : undefined;
}
@@ -39,12 +40,12 @@ type AskCardProps = {
ask: WireAsk;
onAnswer: (id: string, answers: QuestionAnswer[]) => void | Promise<void>;
onDismiss?: () => void | Promise<void>;
draftScope?: string;
draftScope: string;
onStop: () => void;
};
export function AskCard(props: AskCardProps) {
const draftKey = askDraftKey(props.draftScope ?? "default", props.ask);
const draftKey = askDraftKey(props.draftScope, props.ask);
// The same identity owns both cached drafts and live component state. A
// controller can reuse prompt IDs, so changing runtime or turn remounts it.
return <AskCardBody key={draftKey} {...props} draftKey={draftKey} />;
@@ -96,7 +97,7 @@ function AskCardBody({ ask, onAnswer, onStop, draftKey }: AskCardProps & { draft
useEffect(() => {
try {
askDrafts.set(draftKey, { sel: { ...sel }, custom: { ...custom }, answerMode: { ...answerMode }, active, selectedIndex });
askDrafts.write(draftKey, { sel: { ...sel }, custom: { ...custom }, answerMode: { ...answerMode }, active, selectedIndex });
} catch {
// Draft storage is best effort; the live pending ask remains authoritative.
}
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { ReactNode } from "react";
import { Command, Search } from "lucide-react";
import { Activity, AlarmClock, BarChart3, Brain, Command, Cpu, Palette, Puzzle, RotateCw, Search, Server, Settings, SquarePen, TerminalSquare, Trash2 } from "lucide-react";
import { useT } from "../lib/i18n";
import { useMountTransition } from "../lib/useMountTransition";
@@ -22,6 +22,32 @@ import { useMountTransition } from "../lib/useMountTransition";
// Fuzzy match is a small case-insensitive substring scorer — every query
// token must appear in the candidate's title or hint, in order, but they
// may overlap (a real fuzzy matcher would be overkill for 50-200 items).
export type PaletteIconName = "appearance" | "automation" | "extension" | "memory" | "models" | "new" | "reload" | "remote" | "settings" | "tasks" | "terminal" | "trash" | "usage";
const paletteIcons = {
appearance: Palette,
automation: AlarmClock,
extension: Puzzle,
memory: Brain,
models: Cpu,
new: SquarePen,
reload: RotateCw,
remote: Server,
settings: Settings,
tasks: Activity,
terminal: TerminalSquare,
trash: Trash2,
usage: BarChart3,
} as const;
function PaletteIcon({ icon }: { icon?: ReactNode | PaletteIconName }) {
if (typeof icon === "string" && icon in paletteIcons) {
const Icon = paletteIcons[icon as PaletteIconName];
return <Icon size={15} />;
}
return icon ?? <Command size={15} />;
}
export interface PaletteItem {
// id is stable and unique within a single open of the palette.
id: string;
@@ -34,7 +60,7 @@ export interface PaletteItem {
// badge is a right-aligned counter or label (e.g. turn count).
badge?: string;
// icon overrides the default Command icon shown on the left.
icon?: ReactNode;
icon?: ReactNode | PaletteIconName;
// compact renders the item as a grid chip (icon + title, no hint/meta).
compact?: boolean;
// group is the section header this item belongs to.
@@ -257,7 +283,7 @@ export function CommandPalette({
}}
>
<span className="palette__chip-icon" aria-hidden="true">
{it.icon ?? <Command size={15} />}
<PaletteIcon icon={it.icon} />
</span>
<span className="palette__chip-label">{it.title}</span>
</button>
@@ -282,7 +308,7 @@ export function CommandPalette({
}}
>
<span className="palette__item-icon" aria-hidden="true">
{it.icon ?? <Command size={15} />}
<PaletteIcon icon={it.icon} />
</span>
<span className="palette__body">
<span className="palette__title">{it.title}</span>
@@ -24,11 +24,13 @@ function hostOfURL(raw: string | undefined): string {
// unknown mode falls back to form, preserving pre-2025-11-25 behavior.
export function MCPInteractionCard({
interaction,
instanceKey,
busy,
onAnswer,
onOpenLink,
}: {
interaction: WireMCPInteraction;
instanceKey: string;
busy: boolean;
onAnswer: (id: string, action: "accept" | "decline" | "cancel", content?: Record<string, unknown>) => void;
onOpenLink?: (url: string) => void;
@@ -45,7 +47,7 @@ export function MCPInteractionCard({
setValues(initialStructuredValues(schema.fields));
setOpenedLink(false);
setShowErrors(false);
}, [interaction.id, schema.fields]);
}, [instanceKey, schema.fields]);
const firstIssue = schema.fields.find((field) => structuredFieldIssue(field, values[field.key]));
const formBlocked = schema.unsupported || firstIssue !== undefined;
@@ -1,15 +1,16 @@
import { useEffect, useState } from "react";
import { app } from "../lib/bridge";
import { useEffect, useRef, useState } from "react";
import { app, openExternal } from "../lib/bridge";
import { useRemoteNavigationCommand } from "../lib/remoteNavigationCommands";
import { Transcript, type TranscriptProps } from "./Transcript";
import { AskCard } from "./AskCard";
import { ApprovalModal } from "./ApprovalModal";
import { ExtensionFormDialog } from "./ExtensionFormDialog";
import { MCPInteractionCard } from "./MCPInteractionCard";
import { SessionRecoveryBanner, SessionRecoveryPlaceholder } from "./SessionRecoveryBanner";
import { projectSessionAvailability } from "../lib/sessionAvailability";
import type { RemoteSessionApi } from "../lib/useRemoteSession";
export { hydrateRemoteTelemetry, loadRemoteStatusSnapshot } from "../lib/remoteTelemetry";
import type { TabMeta, WireApproval, WireAsk } from "../lib/types";
import type { PromptKind, TabMeta, WireApproval, WireAsk, WireMCPInteraction } from "../lib/types";
import { orderedLocalSubmissions } from "../lib/localSubmissionState";
/**
@@ -30,10 +31,36 @@ export function RemoteSessionSurface({ tab, session, surfaceCommitToken, onSurfa
const hasContent = session.transcript.items.length > 0 || localSubmissions.length > 0 || Boolean(session.transcript.live?.text || session.transcript.live?.reasoning);
const approval = session.transcript.approval as WireApproval | undefined;
const ask = session.transcript.ask as WireAsk | undefined;
const mcpInteraction = session.transcript.mcpInteraction as WireMCPInteraction | undefined;
const extensionForm = session.transcript.extensionForm;
const [actionError, setActionError] = useState("");
const [extensionFormBusy, setExtensionFormBusy] = useState(false);
useEffect(() => { setActionError(""); setExtensionFormBusy(false); }, [session.state, session.surfaceGeneration, tab.id]);
const formKey = extensionForm ? `${tab.id}:${extensionForm.pluginId}:${extensionForm.surfaceId}:${extensionForm.formInstanceId}` : "";
const visibleFormKeyRef = useRef(formKey);
visibleFormKeyRef.current = formKey;
const [busyFormKey, setBusyFormKey] = useState("");
const extensionFormBusy = Boolean(formKey && busyFormKey === formKey);
const visiblePrompt = approval || ask || mcpInteraction;
const promptUpgradeRequired = Boolean(visiblePrompt && !tab.interactionTargetSupported);
const promptIdentityUnavailable = Boolean(visiblePrompt && tab.interactionTargetSupported && (
!tab.remote?.hostId || !tab.sessionId || !tab.sessionGeneration || !visiblePrompt.turnId || !visiblePrompt.runtimeEpoch
));
const promptActionDisabled = promptUpgradeRequired || promptIdentityUnavailable;
const formUpgradeRequired = Boolean(extensionForm && !tab.extensionFormInstanceSupported);
const formIdentityUnavailable = Boolean(extensionForm && tab.extensionFormInstanceSupported && (
!tab.remote?.hostId || !(extensionForm.sessionId ?? tab.sessionId) || !tab.sessionGeneration ||
!extensionForm.generation || !extensionForm.formInstanceExact || !extensionForm.formInstanceId
));
const formActionDisabled = formUpgradeRequired || formIdentityUnavailable;
const capabilityError = promptUpgradeRequired
? "This remote Reasonix Serve must be upgraded before this card can be answered safely."
: promptIdentityUnavailable
? "This card's exact request identity is unavailable. Refresh the session before answering."
: formUpgradeRequired
? "This remote Reasonix Serve must be upgraded before this form can be submitted safely."
: formIdentityUnavailable
? "This form's exact publication identity is unavailable. Refresh the session before submitting."
: "";
useEffect(() => { setActionError(""); setBusyFormKey(""); }, [session.state, session.surfaceGeneration, tab.id]);
const runAction = async (action: () => Promise<unknown>, propagate = false): Promise<void> => {
setActionError("");
try {
@@ -43,12 +70,43 @@ export function RemoteSessionSurface({ tab, session, surfaceCommitToken, onSurfa
if (propagate) throw error;
}
};
const exactPromptTarget = (prompt: { id: string; turnId?: string; runtimeEpoch?: string }, kind: PromptKind) => ({
tabId: tab.id,
hostId: tab.remote?.hostId ?? "",
sessionId: tab.sessionId ?? "",
sessionGeneration: tab.sessionGeneration ?? 0,
promptId: prompt.id,
turnId: prompt.turnId ?? "",
runtimeEpoch: prompt.runtimeEpoch ?? "",
kind,
});
const resolvePrompt = (prompt: { id: string; turnId?: string; runtimeEpoch?: string }, kind: PromptKind, answer: Record<string, unknown>) => {
if (!tab.interactionTargetSupported || !app.ResolveRemoteTabPromptExact) {
return Promise.reject(new Error("This remote Reasonix Serve must be upgraded before this card can be answered safely."));
}
return app.ResolveRemoteTabPromptExact(exactPromptTarget(prompt, kind), answer);
};
const submitExtensionForm = (values: Record<string, unknown>) => {
if (!extensionForm || extensionFormBusy) return;
setExtensionFormBusy(true);
runAction(() => app.SubmitRemoteTabExtensionForm(tab.id, extensionForm.pluginId, extensionForm.surfaceId, values)
.then(() => session.clearExtensionForm(extensionForm.pluginId, extensionForm.surfaceId))
.finally(() => setExtensionFormBusy(false)));
const pending = extensionForm;
const requestKey = formKey;
setBusyFormKey(requestKey);
runAction(async () => {
if (!tab.extensionFormInstanceSupported || !app.SubmitRemoteTabExtensionFormExact) {
throw new Error("This remote Reasonix Serve must be upgraded before this form can be submitted safely.");
}
await app.SubmitRemoteTabExtensionFormExact({
tabId: tab.id,
hostId: tab.remote!.hostId,
sessionId: pending.sessionId ?? tab.sessionId ?? "",
sessionGeneration: tab.sessionGeneration ?? 0,
pluginId: pending.pluginId,
surfaceId: pending.surfaceId,
pluginGeneration: pending.generation ?? 0,
formInstanceId: pending.formInstanceId,
}, values);
session.clearExtensionForm(pending.pluginId, pending.surfaceId, pending.formInstanceId);
}).finally(() => setBusyFormKey((current) => current === requestKey ? "" : current));
};
if (!tab.remote) return null;
@@ -105,50 +163,60 @@ export function RemoteSessionSurface({ tab, session, surfaceCommitToken, onSurfa
/>}
{ready && approval ? (
<fieldset disabled={promptActionDisabled} style={{ display: "contents" }}>
<div className="remote-surface__approval">
<ApprovalModal
key={`${tab.id}:${approval.id}`}
key={`${tab.id}:${tab.sessionGeneration ?? 0}:${approval.runtimeEpoch ?? ""}:${approval.turnId ?? ""}:${approval.id}`}
approval={approval}
cwd={tab.cwd}
tabId={tab.id}
toolApprovalMode={session.composerProfile?.toolApprovalMode}
onAnswer={(allow, sessionScope, persist) => runAction(() => approval.tool === "exit_plan_mode"
? session.resolvePlanDecision(approval.id, allow ? "start_execution" : "revise_plan")
: session.approve(
approval.id,
allow ? (persist ? "persist" : sessionScope ? "session" : "allow") : "deny",
))}
onRevisePlan={(text) => runAction(() => session.resolvePlanDecision(approval.id, "revise_plan", text))}
onExitPlan={() => runAction(() => session.resolvePlanDecision(approval.id, "exit_plan"))}
? resolvePrompt(approval, "plan", { action: allow ? "start_execution" : "revise_plan" })
: resolvePrompt(approval, approval.kind === "recovery" ? "recovery" : "approval", {
allow, session: sessionScope, persist, generation: approval.generation, permissionRevision: approval.permissionRevision,
}))}
onRevisePlan={(text) => runAction(() => resolvePrompt(approval, "plan", { action: "revise_plan", feedback: text }))}
onExitPlan={() => runAction(() => resolvePrompt(approval, "plan", { action: "exit_plan" }))}
onStop={() => runAction(session.cancelTurn)}
/>
</div>
</fieldset>
) : null}
{ready && ask?.questions?.length ? (
<fieldset disabled={promptActionDisabled} style={{ display: "contents" }}>
<AskCard
key={`${tab.id}:${ask.id}`}
key={`${tab.id}:${tab.sessionGeneration ?? 0}:${ask.runtimeEpoch ?? ""}:${ask.turnId ?? ""}:${ask.id}`}
ask={ask}
draftScope={tab.id}
onAnswer={(id, answers) => runAction(() => session.answer(id, answers.map((answer) => ({
QuestionID: answer.questionId,
Selected: answer.selected,
}))), true)}
onDismiss={() => runAction(() => session.answer(ask.id, []), true)}
draftScope={JSON.stringify([tab.remote.hostId, tab.sessionId ?? "", tab.sessionGeneration ?? 0, ask.runtimeEpoch ?? "", ask.turnId ?? "", ask.id])}
onAnswer={(_id, answers) => runAction(() => resolvePrompt(ask, "ask", { questions: answers }), true)}
onDismiss={() => runAction(() => resolvePrompt(ask, "ask", { questions: [] }), true)}
onStop={() => runAction(() => session.cancelTurn())}
/>
</fieldset>
) : null}
{ready && mcpInteraction ? (
<MCPInteractionCard
key={`${tab.id}:${tab.sessionGeneration ?? 0}:${mcpInteraction.runtimeEpoch ?? ""}:${mcpInteraction.turnId ?? ""}:${mcpInteraction.id}`}
instanceKey={JSON.stringify([tab.remote.hostId, tab.sessionId ?? "", tab.sessionGeneration ?? 0, mcpInteraction.runtimeEpoch ?? "", mcpInteraction.turnId ?? "", "mcp", mcpInteraction.id])}
interaction={mcpInteraction}
busy={promptActionDisabled}
onAnswer={(_id, action, content) => void runAction(() => resolvePrompt(mcpInteraction, "mcp", { action, content: content ?? null }))}
onOpenLink={openExternal}
/>
) : null}
{ready && extensionForm ? (
<ExtensionFormDialog
key={`${tab.id}:${extensionForm.pluginId}:${extensionForm.surfaceId}`}
key={formKey}
surface={extensionForm}
busy={extensionFormBusy}
busy={extensionFormBusy || formActionDisabled}
onSubmit={submitExtensionForm}
onCancel={() => submitExtensionForm({ cancelled: true })}
/>
) : null}
{ready && (actionError || session.promptError || session.error) ? (
<div className="remote-surface__detail" role="alert">{actionError || session.promptError || session.error}</div>
{ready && (capabilityError || actionError || session.promptError || session.error) ? (
<div className="remote-surface__detail" role="alert">{capabilityError || actionError || session.promptError || session.error}</div>
) : null}
</div>
</main>
@@ -2,11 +2,11 @@ import type { ComposerInsertRequest, ToolApprovalMode, WireApproval } from "../l
export type ApprovalModalProps = {
approval: WireApproval;
onAnswer: (allow: boolean, session: boolean, persist: boolean) => void;
onResolveRecovery?: (action: "continue" | "continue_task" | "revise", feedback?: string) => void;
onRevisePlan?: (text: string) => void;
onExitPlan?: () => void;
onStop: () => void;
onAnswer: (allow: boolean, session: boolean, persist: boolean) => void | Promise<void>;
onResolveRecovery?: (action: "continue" | "continue_task" | "revise", feedback?: string) => void | Promise<void>;
onRevisePlan?: (text: string) => void | Promise<void>;
onExitPlan?: () => void | Promise<void>;
onStop: () => void | Promise<void>;
cwd?: string;
tabId?: string;
workspaceScopeKey?: string;
@@ -11987,6 +11987,34 @@
"desktop/app.go"
]
},
{
"cancellation": "before-dispatch",
"domain": "desktop/decision_runtime_api.go",
"name": "ResolvePromptForSession",
"owner": "App.ResolvePromptForSession",
"params": [
{
"kind": "object",
"ref": "main.InteractionTargetView"
},
{
"kind": "object",
"ref": "main.PromptAnswerView"
}
],
"returnsError": true,
"scope": {
"inputs": [
"target",
"answer"
],
"kind": "owner-inputs",
"resolver": "App.ResolvePromptForSession"
},
"sources": [
"desktop/decision_runtime_api.go"
]
},
{
"cancellation": "before-dispatch",
"domain": "desktop/decision_runtime_api.go",
@@ -12236,6 +12264,34 @@
"desktop/remote_tab.go"
]
},
{
"cancellation": "before-dispatch",
"domain": "desktop/remote_tab.go",
"name": "ResolveRemoteTabPromptExact",
"owner": "App.ResolveRemoteTabPromptExact",
"params": [
{
"kind": "object",
"ref": "main.InteractionTargetView"
},
{
"kind": "object",
"ref": "main.PromptAnswerView"
}
],
"returnsError": true,
"scope": {
"inputs": [
"target",
"answer"
],
"kind": "owner-inputs",
"resolver": "App.ResolveRemoteTabPromptExact"
},
"sources": [
"desktop/remote_tab.go"
]
},
{
"cancellation": "before-dispatch",
"domain": "desktop/remote_app.go",
@@ -17908,6 +17964,39 @@
"desktop/extensions_app.go"
]
},
{
"cancellation": "before-dispatch",
"domain": "desktop/extensions_app.go",
"name": "SubmitExtensionFormExact",
"owner": "App.SubmitExtensionFormExact",
"params": [
{
"kind": "object",
"ref": "main.ExtensionFormTarget"
},
{
"elem": {
"kind": "any"
},
"key": {
"kind": "string"
},
"kind": "map"
}
],
"returnsError": true,
"scope": {
"inputs": [
"target",
"values"
],
"kind": "owner-inputs",
"resolver": "App.SubmitExtensionFormExact"
},
"sources": [
"desktop/extensions_app.go"
]
},
{
"cancellation": "before-dispatch",
"domain": "desktop/app.go",
@@ -18170,6 +18259,39 @@
"desktop/remote_tab.go"
]
},
{
"cancellation": "before-dispatch",
"domain": "desktop/remote_tab.go",
"name": "SubmitRemoteTabExtensionFormExact",
"owner": "App.SubmitRemoteTabExtensionFormExact",
"params": [
{
"kind": "object",
"ref": "main.ExtensionFormTarget"
},
{
"elem": {
"kind": "any"
},
"key": {
"kind": "string"
},
"kind": "map"
}
],
"returnsError": true,
"scope": {
"inputs": [
"target",
"values"
],
"kind": "owner-inputs",
"resolver": "App.SubmitRemoteTabExtensionFormExact"
},
"sources": [
"desktop/remote_tab.go"
]
},
{
"cancellation": "before-dispatch",
"domain": "desktop/remote_tab.go",
@@ -21828,6 +21950,12 @@
"kind": "string"
}
},
{
"name": "projectionEpoch",
"type": {
"kind": "string"
}
},
{
"name": "runtimeEpoch",
"type": {
@@ -23309,6 +23437,13 @@
"kind": "integer"
}
},
{
"name": "formInstanceId",
"optional": true,
"type": {
"kind": "string"
}
},
{
"name": "kind",
"type": {
@@ -27532,6 +27667,58 @@
}
]
},
"main.ExtensionFormTarget": {
"fields": [
{
"name": "tabId",
"type": {
"kind": "string"
}
},
{
"name": "hostId",
"type": {
"kind": "string"
}
},
{
"name": "sessionId",
"type": {
"kind": "string"
}
},
{
"name": "sessionGeneration",
"type": {
"kind": "integer"
}
},
{
"name": "pluginId",
"type": {
"kind": "string"
}
},
{
"name": "surfaceId",
"type": {
"kind": "string"
}
},
{
"name": "pluginGeneration",
"type": {
"kind": "integer"
}
},
{
"name": "formInstanceId",
"type": {
"kind": "string"
}
}
]
},
"main.ExternalOpenerView": {
"fields": [
{
@@ -29386,6 +29573,58 @@
}
]
},
"main.InteractionTargetView": {
"fields": [
{
"name": "tabId",
"type": {
"kind": "string"
}
},
{
"name": "hostId",
"type": {
"kind": "string"
}
},
{
"name": "sessionId",
"type": {
"kind": "string"
}
},
{
"name": "sessionGeneration",
"type": {
"kind": "integer"
}
},
{
"name": "promptId",
"type": {
"kind": "string"
}
},
{
"name": "turnId",
"type": {
"kind": "string"
}
},
{
"name": "runtimeEpoch",
"type": {
"kind": "string"
}
},
{
"name": "kind",
"type": {
"kind": "string"
}
}
]
},
"main.InvocationRequest": {
"fields": [
{
@@ -30765,6 +31004,23 @@
"kind": "string"
}
},
{
"name": "sessionGeneration",
"type": {
"kind": "integer"
}
},
{
"name": "runtimeStateSnapshot",
"optional": true,
"type": {
"elem": {
"kind": "object",
"ref": "event.RuntimeStateSnapshot"
},
"kind": "nullable"
}
},
{
"name": "cwd",
"type": {
@@ -38296,6 +38552,18 @@
"kind": "boolean"
}
},
{
"name": "interactionTargetSupported",
"type": {
"kind": "boolean"
}
},
{
"name": "extensionFormInstanceSupported",
"type": {
"kind": "boolean"
}
},
{
"name": "topicId",
"type": {
@@ -3,7 +3,7 @@
export const DESKTOP_PROTOCOL_VERSION = 10;
export const DESKTOP_CONTRACT_DIGEST = "sha256:60be2ba6cd33667c18be752b94c5e91c70ae9c50cd249f01fe54a1c19f8bf1ea";
export const DESKTOP_CONTRACT_DIGEST = "sha256:4e245eb0c6858d68102d22eb9cba400ff12318c3e14bef3994e51334f6fdc19d";
export const DESKTOP_COMMANDS = [
"AIRenameSession",
@@ -453,6 +453,7 @@ export const DESKTOP_COMMANDS = [
"ResolvePlanDecisionTab",
"ResolvePlanDecisionTabForTurn",
"ResolvePresentedPathForTab",
"ResolvePromptForSession",
"ResolvePromptForTab",
"ResolveRecovery",
"ResolveRecoveryTab",
@@ -460,6 +461,7 @@ export const DESKTOP_COMMANDS = [
"ResolveReferencePathForTab",
"ResolveRemotePresentedPathForTab",
"ResolveRemoteTabPlanDecision",
"ResolveRemoteTabPromptExact",
"ResolveRemoteWorkspacePathForTab",
"ResolveToolRecoveryForTab",
"ResolveWorkspacePathForTab",
@@ -670,12 +672,14 @@ export const DESKTOP_COMMANDS = [
"SubmitEditedDisplayToTab",
"SubmitEditedDisplayToTabWithID",
"SubmitExtensionForm",
"SubmitExtensionFormExact",
"SubmitInitialGoalToTab",
"SubmitInitialGoalToTabWithID",
"SubmitInvocationsToTab",
"SubmitInvocationsToTabWithID",
"SubmitRemoteTab",
"SubmitRemoteTabExtensionForm",
"SubmitRemoteTabExtensionFormExact",
"SubmitRemoteTabWithSubmission",
"SubmitToTab",
"SubmitToTabWithID",
@@ -1176,6 +1180,7 @@ export interface RuntimeStateSnapshot {
hostId?: string;
sessionId?: string;
sessionCodec?: string;
projectionEpoch: string;
runtimeEpoch: string;
activityRevision: number;
revision: number;
@@ -1423,6 +1428,7 @@ export interface ExtensionSurface {
surfaceId: string;
sessionId?: string;
generation?: number;
formInstanceId?: string;
kind: string;
status?: ExtensionStatus | null;
card?: ExtensionCard | null;
@@ -2188,6 +2194,17 @@ export interface ExtensionActionView {
description?: string;
}
export interface ExtensionFormTarget {
tabId: string;
hostId: string;
sessionId: string;
sessionGeneration: number;
pluginId: string;
surfaceId: string;
pluginGeneration: number;
formInstanceId: string;
}
export interface ExternalOpenerView {
id: string;
name: string;
@@ -2564,6 +2581,17 @@ export interface InstructionDiagnostic {
message: string;
}
export interface InteractionTargetView {
tabId: string;
hostId: string;
sessionId: string;
sessionGeneration: number;
promptId: string;
turnId: string;
runtimeEpoch: string;
kind: string;
}
export interface InvocationRequest {
name: string;
kind: string;
@@ -2833,6 +2861,8 @@ export interface Meta {
session?: SessionRef | null;
sessionRevision?: number;
sessionDigest?: string;
sessionGeneration: number;
runtimeStateSnapshot?: RuntimeStateSnapshot | null;
cwd: string;
workspaceRoot?: string;
workspaceName?: string;
@@ -4224,6 +4254,8 @@ export interface TabMeta {
remote?: RemoteTabRef | null;
remoteState?: string;
forkTargetsSupported: boolean;
interactionTargetSupported: boolean;
extensionFormInstanceSupported: boolean;
topicId: string;
topicTitle: string;
sessionPath?: string;
@@ -5864,6 +5896,7 @@ export interface GeneratedDesktopCommands {
ResolvePlanDecisionTab(arg0: string, arg1: string, arg2: string): Promise<void>;
ResolvePlanDecisionTabForTurn(arg0: string, arg1: string, arg2: string, arg3: string, arg4: string): Promise<void>;
ResolvePresentedPathForTab(arg0: string, arg1: string, arg2: string): Promise<string>;
ResolvePromptForSession(arg0: InteractionTargetView, arg1: PromptAnswerView): Promise<void>;
ResolvePromptForTab(arg0: string, arg1: string, arg2: string, arg3: string, arg4: string, arg5: PromptAnswerView): Promise<void>;
ResolveRecovery(arg0: string, arg1: string, arg2: string): Promise<void>;
ResolveRecoveryTab(arg0: string, arg1: string, arg2: string, arg3: string): Promise<void>;
@@ -5871,6 +5904,7 @@ export interface GeneratedDesktopCommands {
ResolveReferencePathForTab(arg0: string, arg1: string): Promise<string>;
ResolveRemotePresentedPathForTab(arg0: string, arg1: string, arg2: string, arg3: string): Promise<string>;
ResolveRemoteTabPlanDecision(arg0: string, arg1: string, arg2: string, arg3: string): Promise<void>;
ResolveRemoteTabPromptExact(arg0: InteractionTargetView, arg1: PromptAnswerView): Promise<void>;
ResolveRemoteWorkspacePathForTab(arg0: string, arg1: string, arg2: string, arg3: string): Promise<string>;
ResolveToolRecoveryForTab(arg0: string, arg1: ToolRecoveryRequest): Promise<ToolRecoverySnapshot>;
ResolveWorkspacePathForTab(arg0: string, arg1: string): Promise<string>;
@@ -6081,12 +6115,14 @@ export interface GeneratedDesktopCommands {
SubmitEditedDisplayToTab(arg0: string, arg1: string, arg2: string, arg3: string): Promise<void>;
SubmitEditedDisplayToTabWithID(arg0: string, arg1: string, arg2: string, arg3: string, arg4: string): Promise<void>;
SubmitExtensionForm(arg0: string, arg1: string, arg2: string, arg3: Record<string, unknown>): Promise<void>;
SubmitExtensionFormExact(arg0: ExtensionFormTarget, arg1: Record<string, unknown>): Promise<void>;
SubmitInitialGoalToTab(arg0: string, arg1: string, arg2: string, arg3: string, arg4: InvocationRequest[], arg5: string, arg6: string): Promise<string[]>;
SubmitInitialGoalToTabWithID(arg0: string, arg1: string, arg2: string, arg3: string, arg4: InvocationRequest[], arg5: string, arg6: string, arg7: string): Promise<string[]>;
SubmitInvocationsToTab(arg0: string, arg1: string, arg2: string, arg3: InvocationRequest[]): Promise<void>;
SubmitInvocationsToTabWithID(arg0: string, arg1: string, arg2: string, arg3: InvocationRequest[], arg4: string): Promise<void>;
SubmitRemoteTab(arg0: string, arg1: string): Promise<void>;
SubmitRemoteTabExtensionForm(arg0: string, arg1: string, arg2: string, arg3: Record<string, unknown>): Promise<void>;
SubmitRemoteTabExtensionFormExact(arg0: ExtensionFormTarget, arg1: Record<string, unknown>): Promise<void>;
SubmitRemoteTabWithSubmission(arg0: string, arg1: string, arg2: string): Promise<void>;
SubmitToTab(arg0: string, arg1: string): Promise<void>;
SubmitToTabWithID(arg0: string, arg1: string, arg2: string): Promise<void>;
+13
View File
@@ -10,6 +10,7 @@ import type {
DesktopCommandName,
HistoryWindowPage,
HistoryWindowRequest,
InteractionTargetView,
LegacyEmptySessionCleanupStatus,
MarkdownSVGView,
MessageFieldPage,
@@ -31,6 +32,8 @@ import type {
SessionRef,
SessionSelector,
ComposerTarget,
ExtensionFormTarget,
PromptAnswerView,
WorkspaceSessionPage,
WorkspaceSnapshot,
} from "../generated/desktopContract.generated";
@@ -410,6 +413,7 @@ export interface AppBindings extends SessionExportBindings, SessionLifecycleBind
action?: string; feedback?: string; content?: Record<string, unknown> | null;
generation?: number; permissionRevision?: number;
}): Promise<void>;
ResolvePromptForSession?(target: InteractionTargetView, answer: PromptAnswerView): Promise<void>;
PendingPromptIdentitiesForTab?(tabID: string): Promise<Array<{ promptId: string; turnId: string; runtimeEpoch?: string; kind: string }>>;
ReplayPendingPromptIdentitiesForTab?(tabID: string): Promise<Array<{ promptId: string; turnId: string; runtimeEpoch?: string; kind: string }>>;
ReplayPendingPrompts(): Promise<void>;
@@ -554,6 +558,9 @@ export interface AppBindings extends SessionExportBindings, SessionLifecycleBind
ExtensionActions(tabID: string): Promise<ExtensionActionView[]>;
InvokeExtensionAction(tabID: string, name: string, args: Record<string, string>): Promise<string>;
SubmitExtensionForm(tabID: string, pluginID: string, surfaceID: string, values: Record<string, unknown>): Promise<void>;
SubmitExtensionFormExact(target: ExtensionFormTarget, values: Record<string, unknown>): Promise<void>;
ResolveRemoteTabPromptExact(target: InteractionTargetView, answer: PromptAnswerView): Promise<void>;
SubmitRemoteTabExtensionFormExact(target: ExtensionFormTarget, values: Record<string, unknown>): Promise<void>;
AddMCPServer(input: MCPServerInput): Promise<number>;
InstallMCPServer(input: MCPServerInput): Promise<MCPInstallResult>;
UpdateMCPServer(name: string, input: MCPServerInput): Promise<void>;
@@ -3243,6 +3250,9 @@ function makeMockApp(): AppBindings {
throw new Error(`unsupported prompt kind: ${kind}`);
});
},
async ResolvePromptForSession(target, answer) {
await this.ResolvePromptForTab?.(target.tabId, target.promptId, target.turnId, target.runtimeEpoch, target.kind, answer);
},
async ReplayPendingPrompts() {},
async ReplayPendingPromptsForTab(_tabID) {},
async ConfirmAction(req) {
@@ -6008,6 +6018,9 @@ function makeMockApp(): AppBindings {
return "";
},
async SubmitExtensionForm() {},
async SubmitExtensionFormExact() {},
async ResolveRemoteTabPromptExact() {},
async SubmitRemoteTabExtensionFormExact() {},
...remoteProjects.bindings,
async CleanRemoteLegacyWorkbenchData() {},
};
@@ -1,4 +1,5 @@
import { modeHasAutoApproveTools, normalizeMode, normalizeToolApprovalMode, type Meta, type TabMeta } from "./types";
import { sameSessionIdentity } from "./sessionIdentity";
export function metaFromTab(tab: TabMeta, existing?: Meta): Meta {
const cwd = tab.cwd || tab.workspaceRoot || existing?.cwd || "";
@@ -38,6 +39,6 @@ export function metaFromTab(tab: TabMeta, existing?: Meta): Meta {
goal: tab.goal ?? existing?.goal,
goalStatus: tab.goalStatus ?? existing?.goalStatus,
goalView: tab.goalView ?? existing?.goalView,
canonicalTodos: existing?.canonicalTodos,
canonicalTodos: sameSessionIdentity(tab, existing) ? existing?.canonicalTodos : undefined,
};
}
+47 -12
View File
@@ -1,6 +1,38 @@
import type { AppBindings } from "./bridge";
import type { InteractionTarget } from "./interactionTarget";
type ExactPromptBinding = Pick<AppBindings, "ListTabs" | "ResolvePromptForTab">;
type ExactPromptBinding = Pick<AppBindings, "ResolvePromptForSession" | "ResolvePromptForTab" | "PendingPromptIdentitiesForTab">;
export async function resolvePromptForSession(
binding: ExactPromptBinding,
target: InteractionTarget,
answer: Record<string, unknown>,
): Promise<void> {
if (!binding.ResolvePromptForSession) throw new Error("exact session prompt submission is unavailable; upgrade the host");
if (!target.sessionId || !target.sessionGeneration) throw new Error("prompt session identity is unavailable; refresh the card");
let turnId = target.turnId;
let runtimeEpoch = target.runtimeEpoch;
if (!turnId) {
if (!binding.PendingPromptIdentitiesForTab) throw new Error("prompt identity is unavailable; refresh or upgrade the host");
const matches = (await binding.PendingPromptIdentitiesForTab(target.tabId)).filter((candidate) => (
candidate.promptId === target.promptId && candidate.kind === target.kind &&
(!runtimeEpoch || !candidate.runtimeEpoch || candidate.runtimeEpoch === runtimeEpoch)
));
if (matches.length !== 1) throw new Error("prompt is stale or its exact identity is unavailable");
turnId = matches[0].turnId;
runtimeEpoch = matches[0].runtimeEpoch;
}
await binding.ResolvePromptForSession({
tabId: target.tabId,
hostId: target.hostId ?? "local",
sessionId: target.sessionId,
sessionGeneration: target.sessionGeneration,
promptId: target.promptId,
turnId,
runtimeEpoch: runtimeEpoch ?? "",
kind: target.kind,
}, answer);
}
export async function resolvePromptForTab(
binding: ExactPromptBinding,
@@ -11,15 +43,18 @@ export async function resolvePromptForTab(
knownTurnId?: string,
knownRuntimeEpoch?: string,
): Promise<void> {
// Ask cards must refresh the active turn from the tab owner immediately
// before submission. A locally captured turn is useful for the identity
// fence, but it is not authoritative during startup/rebind races.
// Other prompt kinds keep their immutable card turn and only consult
// ListTabs when the card arrived before local turn state was available.
const tab = kind === "ask" || !knownTurnId
? (await binding.ListTabs()).find((candidate) => candidate.id === tabId)
: undefined;
const turnId = kind === "ask" ? tab?.turnId : (knownTurnId ?? tab?.turnId);
if (!binding.ResolvePromptForTab || !turnId) throw new Error("active turn identity is unavailable");
await binding.ResolvePromptForTab(tabId, promptId, turnId, knownRuntimeEpoch ?? tab?.runtime?.epoch ?? "", kind, answer);
if (!binding.ResolvePromptForTab) throw new Error("exact prompt submission is unavailable; upgrade the host");
let turnId = knownTurnId;
let runtimeEpoch = knownRuntimeEpoch;
if (!turnId) {
if (!binding.PendingPromptIdentitiesForTab) throw new Error("prompt identity is unavailable; refresh or upgrade the host");
const matches = (await binding.PendingPromptIdentitiesForTab(tabId)).filter((candidate) => (
candidate.promptId === promptId && candidate.kind === kind &&
(!knownRuntimeEpoch || !candidate.runtimeEpoch || candidate.runtimeEpoch === knownRuntimeEpoch)
));
if (matches.length !== 1) throw new Error("prompt is stale or its exact identity is unavailable");
turnId = matches[0].turnId;
runtimeEpoch = matches[0].runtimeEpoch;
}
await binding.ResolvePromptForTab(tabId, promptId, turnId, runtimeEpoch ?? "", kind, answer);
}
+2 -2
View File
@@ -13,10 +13,10 @@ export async function resolveActiveTurnId(binding: Pick<AppBindings, "ListTabs">
return authoritative;
}
type AskAnswerBindings = Pick<AppBindings, "ListTabs" | "ResolvePromptForTab">;
type AskAnswerBindings = Pick<AppBindings, "ResolvePromptForTab" | "PendingPromptIdentitiesForTab">;
export async function resolvePromptForTab(
binding: Pick<AppBindings, "ListTabs" | "ResolvePromptForTab">,
binding: Pick<AppBindings, "ResolvePromptForTab" | "PendingPromptIdentitiesForTab">,
tabId: string,
promptId: string,
kind: string,
@@ -0,0 +1,35 @@
export type InteractionDraftStore<Value> = {
read(key: string): Value | undefined;
write(key: string, value: Value): void;
delete(key: string): void;
releaseScope(scope: string): void;
size(): number;
};
/** Request-owned, bounded volatile state. Entries survive a component remount,
* but cannot become an unbounded process-global cache. */
export function createInteractionDraftStore<Value>(limit = 128): InteractionDraftStore<Value> {
const entries = new Map<string, { scope: string; value: Value }>();
const touch = (key: string, entry: { scope: string; value: Value }) => {
entries.delete(key);
entries.set(key, entry);
};
return {
read(key) {
const entry = entries.get(key);
if (!entry) return undefined;
touch(key, entry);
return entry.value;
},
write(key, value) {
const split = key.indexOf("\u0000");
touch(key, { scope: split < 0 ? key : key.slice(0, split), value });
while (entries.size > limit) entries.delete(entries.keys().next().value!);
},
delete(key) { entries.delete(key); },
releaseScope(scope) {
for (const [key, entry] of entries) if (entry.scope === scope) entries.delete(key);
},
size: () => entries.size,
};
}
@@ -0,0 +1,40 @@
export type InteractionKind = "ask" | "approval" | "plan" | "recovery" | "mcp";
export type InteractionTarget = Readonly<{
tabId: string;
sessionKey: string;
hostId?: string;
sessionId?: string;
sessionGeneration?: number;
promptId: string;
turnId?: string;
runtimeEpoch?: string;
kind: InteractionKind;
instanceKey: string;
requestGeneration?: number;
permissionRevision?: number;
}>;
export function interactionInstanceKey(target: Omit<InteractionTarget, "instanceKey">): string {
return JSON.stringify([
target.sessionKey,
target.hostId ?? "",
target.sessionId ?? "",
target.sessionGeneration ?? 0,
target.runtimeEpoch ?? "",
target.turnId ?? "",
target.kind,
target.promptId,
target.requestGeneration ?? 0,
target.permissionRevision ?? 0,
]);
}
export function sameInteractionIdentity(
prompt: { id: string; turnId?: string; runtimeEpoch?: string } | undefined,
target: Pick<InteractionTarget, "promptId" | "turnId" | "runtimeEpoch">,
): boolean {
return Boolean(prompt) && prompt?.id === target.promptId &&
(target.turnId === undefined || prompt.turnId === target.turnId) &&
(target.runtimeEpoch === undefined || prompt.runtimeEpoch === target.runtimeEpoch);
}
+2
View File
@@ -32,6 +32,8 @@ export interface RemoteTabMetaFields {
remoteState?: RemoteTabStateValue;
/** Host-emitted: this tab's serve advertised session-fork-targets-v1. */
forkTargetsSupported?: boolean;
interactionTargetSupported?: boolean;
extensionFormInstanceSupported?: boolean;
}
export interface RemoteProjectNodeFields {
@@ -1,11 +1,13 @@
import type { ProjectRuntimeTopic } from "./types";
import type { PendingInteraction, RecoveryStatus, Todo } from "../generated/desktopContract.generated";
import { sameSessionIdentity, type SessionIdentity } from "./sessionIdentity";
export interface RuntimeState {
schemaVersion: number;
hostId?: string;
sessionId?: string;
sessionCodec?: string;
projectionEpoch?: string;
runtimeEpoch: string;
activityRevision: number;
revision: number;
@@ -29,6 +31,13 @@ export interface RuntimeState {
activity: string;
recovery?: RecoveryStatus | null;
}
export function acceptSessionRuntimeSnapshot(current: RuntimeState | undefined, next: RuntimeState, allowProducerBaseline = false): RuntimeState {
if (!current) return next;
if (current.projectionEpoch !== next.projectionEpoch) return allowProducerBaseline ? next : current;
if (next.revision < current.revision) return current;
return next.revision === current.revision ? current : next;
}
export interface RuntimeSession {
tabId: string;
scope: string;
@@ -50,6 +59,21 @@ export interface RuntimeProjection {
topics: ProjectRuntimeTopic[];
}
/** A tab is a reusable surface, not a session identity. Unknown/blank bindings
* must not adopt the previous session while navigation metadata catches up. */
export function selectRuntimeSession(snapshot: RuntimeProjection | undefined, tabId: string | undefined, identity: SessionIdentity | string | undefined) {
const target = typeof identity === "string" ? { sessionPath: identity } : identity;
if (!tabId || !target) return undefined;
if (typeof identity !== "string" && target.sessionGeneration == null) return undefined;
return snapshot?.sessions.find(session => session.open && session.tabId === tabId && sameSessionIdentity(target, {
session: target.session?.sessionId && session.sessionId
? { hostId: session.hostId || "local", sessionId: session.sessionId }
: undefined,
sessionPath: session.sessionPath,
sessionGeneration: session.sessionGeneration,
}));
}
export function selectRuntime(session?: RuntimeSession, failed = false) {
const state = session?.state;
const known = state?.schemaVersion === 1;
+2
View File
@@ -386,6 +386,7 @@ export interface WireExtensionSurface {
surfaceId: string;
sessionId?: string;
generation?: number;
formInstanceId?: string;
kind: string; // "status" | "card" | "form" | "notification"
status?: WireExtensionStatus;
card?: WireExtensionCard;
@@ -996,6 +997,7 @@ export interface Meta extends RemoteSessionMetaFields {
sessionRevision?: number;
sessionDigest?: string;
sessionGeneration?: number;
runtimeStateSnapshot?: import("./runtimeStateStore").RuntimeState;
cwd: string;
workspaceRoot?: string;
workspaceName?: string;
+200 -86
View File
@@ -4,6 +4,7 @@
import { resetTurnTiming, confirmPendingUser, installTranscriptRecords, startLocalSubmission, submissionBindingCurrent } from "./submissionReducer";
import { runtimeStatusSnapshotIsStale } from "./runtimeStatusFreshness";
import { useRuntimeSession } from "./useRuntimeState";
import { acceptSessionRuntimeSnapshot, type RuntimeState } from "./runtimeStateStore";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { asArray } from "./array";
import { createControllerModelCommands } from "./controllerModelCommands";
@@ -20,7 +21,7 @@ import { formatInboxCancelError } from "./inboxError";
import type { MessageActionScope, MessageActionState } from "./messageActions";
import { mergeRateBand, type AggregatedRateBand } from "./costRateBand";
import { requestSessionCancel, type CancelOutcome } from "./inboxCancel";
import { answerPromptForActiveTurn, normalizeTurnSubmit, resolveActiveTurnId, resolvePromptForTab } from "./inboxSubmit";
import { normalizeTurnSubmit, resolveActiveTurnId } from "./inboxSubmit";
import { findTabAfterSubmitFailure, reduceManagementConfirmation, reduceSubmitFailure } from "./turnSubmissionFailure";
import {
checkpointLocalSubmission,
@@ -83,6 +84,7 @@ import { reduceHistoryWindowState } from "./historyWindowState";
import { withRemoteProviderUnreachable, withRemoteTurnInterrupted } from "./remoteTurnState";
import type { NavigationResult, SurfaceDataCommit, SurfaceDataOutcome } from "./navigationSurfaceTransition";
import { sameTodoList } from "./todoVisibility";
import { interactionInstanceKey, sameInteractionIdentity, type InteractionKind, type InteractionTarget } from "./interactionTarget";
import { resolveSnapshotTurnStartedAt, resolveTurnStartedAt, snapshotPredatesTurnLifecycle } from "./turnTiming";
import { useRemoteTabSwitch } from "./useRemoteTabSwitch";
import { useNavigationIntentFence } from "./useNavigationIntentFence";
@@ -130,6 +132,10 @@ import type {
WireShellExecution,
} from "./types";
function resolvePromptForSession(target: InteractionTarget, answer: Record<string, unknown>): Promise<void> {
return import("./exactPromptSubmit").then(({ resolvePromptForSession: submit }) => submit(app, target, answer));
}
export { foregroundRunningFromRuntimeMeta } from "./runtimeMeta";
export { historyMessagesToItems, historyToolError, isReadOnlyTool } from "./historyItems";
export {
@@ -389,7 +395,10 @@ export interface ExtensionStatusEntry {
export interface ExtensionFormState {
pluginId: string;
surfaceId: string;
sessionId?: string;
generation?: number;
formInstanceId: string;
formInstanceExact: boolean;
form: WireExtensionForm;
}
export interface ExtensionNotificationEntry {
@@ -411,6 +420,70 @@ export function extensionSurfaceKey(surface: Pick<WireExtensionSurface, "pluginI
export function acceptsExtensionGeneration(stored: number | undefined, incoming: number | undefined): boolean {
return incoming === undefined || stored === undefined || incoming >= stored;
}
function promptForInteraction(state: State, target: InteractionTarget): WireApproval | WireAsk | WireMCPInteraction | undefined {
if (target.kind === "ask") return state.ask;
if (target.kind === "mcp") return state.mcpInteraction;
return state.approval;
}
function stateOwnsInteraction(state: State, target: InteractionTarget): boolean {
if (!target.sessionId || !target.sessionGeneration || !state.meta?.session?.sessionId || !state.meta.sessionGeneration) return false;
if (state.meta.session.sessionId !== target.sessionId || state.meta.sessionGeneration !== target.sessionGeneration) return false;
if (target.hostId && state.meta.session.hostId !== target.hostId) return false;
const currentSessionKey = sessionIdentityStableKey(state.meta);
if (currentSessionKey && target.sessionKey && currentSessionKey !== target.sessionKey) return false;
const prompt = promptForInteraction(state, target);
if (!sameInteractionIdentity(prompt, target)) return false;
if (target.requestGeneration !== undefined && prompt && "generation" in prompt && prompt.generation !== target.requestGeneration) return false;
if (target.permissionRevision !== undefined && prompt && "permissionRevision" in prompt && prompt.permissionRevision !== target.permissionRevision) return false;
return true;
}
function promptInstanceKeyForState(
state: State,
prompt: WireApproval | WireAsk | WireMCPInteraction,
kind: InteractionKind,
): string {
const base = {
tabId: "",
sessionKey: sessionIdentityStableKey(state.meta),
hostId: state.meta?.session?.hostId,
sessionId: state.meta?.session?.sessionId,
sessionGeneration: state.meta?.sessionGeneration,
promptId: prompt.id,
turnId: prompt.turnId,
runtimeEpoch: prompt.runtimeEpoch,
kind,
requestGeneration: "generation" in prompt ? prompt.generation : undefined,
permissionRevision: "permissionRevision" in prompt ? prompt.permissionRevision : undefined,
};
return interactionInstanceKey(base);
}
function interactionTargetFromState(
tabId: string,
state: State | undefined,
kind: InteractionKind,
promptId: string,
): InteractionTarget {
const prompt = state ? promptForInteraction(state, { kind } as InteractionTarget) : undefined;
const base = {
tabId,
sessionKey: sessionIdentityStableKey(state?.meta) || tabId,
hostId: state?.meta?.session?.hostId,
sessionId: state?.meta?.session?.sessionId,
sessionGeneration: state?.meta?.sessionGeneration,
promptId,
turnId: prompt?.turnId ?? state?.activeTurnId,
runtimeEpoch: prompt?.runtimeEpoch ?? state?.meta?.runtime?.epoch,
kind,
requestGeneration: prompt && "generation" in prompt ? prompt.generation : undefined,
permissionRevision: prompt && "permissionRevision" in prompt ? prompt.permissionRevision : undefined,
};
return { ...base, instanceKey: interactionInstanceKey(base) };
}
// Mid-turn steer messages are recorded as info notices carrying this prefix —
// both live (the "steer" event below) and in replayed history (desktop/app.go
// prefixes persisted steers the same way). The prefix is the only durable
@@ -421,10 +494,10 @@ function isStalePromptError(error: unknown): boolean {
return /active turn|runtime changed|stale/i.test(errorMessage(error));
}
function handlePromptFailure(dispatchTo: (tabId: string, action: Action) => void, tabId: string, id: string, epoch: number, error: unknown, kind?: "approval" | "ask" | "mcp") {
if (isStalePromptError(error) && kind) dispatchTo(tabId, { type: "expire_prompt", id, epoch, kind });
else if (kind) dispatchTo(tabId, { type: "submit_prompt_failed", id, epoch });
replayPendingPromptsForActiveTab(tabId);
function handlePromptFailure(dispatchTo: (tabId: string, action: Action) => void, target: InteractionTarget, epoch: number, error: unknown) {
if (isStalePromptError(error)) dispatchTo(target.tabId, { type: "expire_prompt", target, epoch });
else dispatchTo(target.tabId, { type: "submit_prompt_failed", target, epoch });
replayPendingPromptsForActiveTab(target.tabId);
}
export function isSteerNoticeText(text: string): boolean {
@@ -551,6 +624,7 @@ export interface State extends ReadStatusHost, ForkTurnState {
// to reject (#6432 round 2: idle-applied-before-replay, and
// running=true/pendingPrompt=false snapshots that never clear approval/ask).
resolvedPromptId?: string;
resolvedPromptKey?: string;
// Monotonic per-tab prompt-id namespace generation. Approval/ask ids restart
// from "1" whenever the backend controller is rebuilt, so any id captured
// before the bump (an in-flight prompt answer or mode-switch RPC) must not
@@ -594,6 +668,8 @@ export interface State extends ReadStatusHost, ForkTurnState {
// Last accepted generation per extension surface key; guards against
// re-ordered publications (acceptsExtensionGeneration).
extensionGenerations: Record<string, number>;
/** Last binding-validated runtime snapshot accepted from Meta or runtime sync. */
runtimeStateSnapshot?: RuntimeState;
// Speculative sampling-attempt journal for Codex-style stream replay.
// Host-local only; never hydrated from history.
streamAttemptJournal?: StreamAttemptJournal;
@@ -838,6 +914,7 @@ export type Action =
| { type: "cancel_requested" }
| { type: "meta"; meta: Meta }
| { type: "optimistic_meta"; meta: Meta }
| { type: "runtime_snapshot"; snapshot: RuntimeState }
| { type: "context"; context: ContextInfo }
| { type: "balance"; balance: BalanceInfo }
| { type: "effort"; effort: EffortInfo }
@@ -867,14 +944,14 @@ export type Action =
| { type: "history_newer_start" }
| { type: "history_newer_error"; error?: string }
| { type: "local_notice"; level: "info" | "warn"; text: string; preserveRuntime?: boolean }
| { type: "clearApproval" }
| { type: "clearApproval"; target?: InteractionTarget }
| { type: "clearAsk" }
| { type: "expire_prompt"; id: string; epoch: number; kind: "approval" | "ask" | "mcp" }
| { type: "clearExtensionForm" }
| { type: "expire_prompt"; target: InteractionTarget; epoch: number }
| { type: "clearExtensionForm"; identity?: Pick<ExtensionFormState, "pluginId" | "surfaceId" | "formInstanceId"> }
| { type: "extension_notifications_drained" }
| { type: "approval_drained"; ids: string[]; epoch: number }
| { type: "ask_submit_succeeded"; id: string; epoch: number }
| { type: "submit_prompt_failed"; id: string; epoch: number }
| { type: "ask_submit_succeeded"; target: InteractionTarget; epoch: number }
| { type: "submit_prompt_failed"; target: InteractionTarget; epoch: number }
| { type: "controller_rebuilt" }
| { type: "reset" }
| { type: "context_panel_refresh" };
@@ -1099,7 +1176,15 @@ function applyExtensionForm(s: State, surface: WireExtensionSurface): State {
if (!form) return s;
return {
...s,
extensionForm: { pluginId: surface.pluginId, surfaceId: surface.surfaceId, generation: surface.generation, form },
extensionForm: {
pluginId: surface.pluginId,
surfaceId: surface.surfaceId,
sessionId: surface.sessionId,
generation: surface.generation,
formInstanceId: surface.formInstanceId ?? JSON.stringify([surface.pluginId, surface.surfaceId, surface.generation ?? 0]),
formInstanceExact: Boolean(surface.formInstanceId),
form,
},
};
}
@@ -1715,14 +1800,17 @@ function applyEvent(s: State, e: WireEvent, preserveToolPayloads = false): State
return { ...s, seq: s.seq + 1, items: [...s.items, { kind: "notice", id: `s${s.seq}`, level: "info", text: `${STEER_NOTICE_PREFIX}${e.text ?? ""}`, inboxItemId: e.itemId }] };
case "approval_request": {
if (s.cancelRequested) return s;
const approval = e.approval ? { ...e.approval, turnId: e.turnId ?? e.approval.turnId, runtimeEpoch: e.runtimeEpoch ?? e.approval.runtimeEpoch } : undefined;
const approvalKind: InteractionKind = approval?.kind === "recovery" || approval?.recovery
? "recovery" : approval?.tool === "exit_plan_mode" ? "plan" : "approval";
// A delayed re-delivery of a prompt the user already answered locally
// (clearApproval) must not resurrect it — no downstream snapshot is
// guaranteed to ever reject it again (#6432 round 2).
if (e.approval?.id !== undefined && e.approval.id === s.resolvedPromptId) return s;
if (approval && (promptInstanceKeyForState(s, approval, approvalKind) === s.resolvedPromptKey || (!s.resolvedPromptKey && approval.id === s.resolvedPromptId))) return s;
return beginPromptWait({
...s,
activeTurnId: e.turnId ?? s.activeTurnId,
approval: e.approval ? { ...e.approval, turnId: e.turnId ?? e.approval.turnId, runtimeEpoch: e.runtimeEpoch ?? e.approval.runtimeEpoch } : e.approval,
approval,
// A replay of the SAME prompt (post-answer delayed delivery, or the
// #6429 re-arm after activation) keeps the original arrival time; only
// a genuinely new prompt id re-anchors it (#6432 reverse race).
@@ -1736,11 +1824,12 @@ function applyEvent(s: State, e: WireEvent, preserveToolPayloads = false): State
}
case "ask_request": {
if (s.cancelRequested) return s;
if (e.ask?.id !== undefined && e.ask.id === s.resolvedPromptId) return s;
const ask = e.ask ? { ...e.ask, turnId: e.turnId ?? e.ask.turnId, runtimeEpoch: e.runtimeEpoch ?? e.ask.runtimeEpoch } : undefined;
if (ask && (promptInstanceKeyForState(s, ask, "ask") === s.resolvedPromptKey || (!s.resolvedPromptKey && ask.id === s.resolvedPromptId))) return s;
return beginPromptWait({
...s,
activeTurnId: e.turnId ?? s.activeTurnId,
ask: e.ask ? { ...e.ask, turnId: e.turnId ?? e.ask.turnId, runtimeEpoch: e.runtimeEpoch ?? e.ask.runtimeEpoch } : e.ask,
ask,
promptArrivedAt: e.ask?.id === s.promptArrivedId ? s.promptArrivedAt : promptEventClock(),
promptArrivedId: e.ask?.id,
pendingPrompt: true,
@@ -1751,11 +1840,12 @@ function applyEvent(s: State, e: WireEvent, preserveToolPayloads = false): State
}
case "mcp_interaction": {
if (s.cancelRequested) return s;
if (e.mcpInteraction?.id !== undefined && e.mcpInteraction.id === s.resolvedPromptId) return s;
const interaction = e.mcpInteraction ? { ...e.mcpInteraction, turnId: e.turnId ?? e.mcpInteraction.turnId, runtimeEpoch: e.runtimeEpoch ?? e.mcpInteraction.runtimeEpoch } : undefined;
if (interaction && (promptInstanceKeyForState(s, interaction, "mcp") === s.resolvedPromptKey || (!s.resolvedPromptKey && interaction.id === s.resolvedPromptId))) return s;
return beginPromptWait({
...s,
activeTurnId: e.turnId ?? s.activeTurnId,
mcpInteraction: e.mcpInteraction ? { ...e.mcpInteraction, turnId: e.turnId ?? e.mcpInteraction.turnId, runtimeEpoch: e.runtimeEpoch ?? e.mcpInteraction.runtimeEpoch } : e.mcpInteraction,
mcpInteraction: interaction,
promptArrivedAt: e.mcpInteraction?.id === s.promptArrivedId ? s.promptArrivedAt : promptEventClock(),
promptArrivedId: e.mcpInteraction?.id,
pendingPrompt: true,
@@ -2083,9 +2173,25 @@ function reduceState(s: State, a: Action): State {
}
case "meta": {
const meta = a.meta.sessionPath === undefined && s.meta?.sessionPath !== undefined ? { ...a.meta, sessionPath: s.meta.sessionPath } : a.meta;
return sameMeta(s.meta, meta) ? s : { ...s, meta };
const runtimeStateSnapshot = meta.runtimeStateSnapshot
? acceptSessionRuntimeSnapshot(s.runtimeStateSnapshot, meta.runtimeStateSnapshot, true)
: s.runtimeStateSnapshot;
const acceptedMeta = runtimeStateSnapshot?.todos !== undefined
? { ...meta, canonicalTodos: runtimeStateSnapshot.todos }
: meta;
return sameMeta(s.meta, acceptedMeta) && runtimeStateSnapshot === s.runtimeStateSnapshot
? s
: { ...s, meta: acceptedMeta, runtimeStateSnapshot };
}
case "optimistic_meta": return sameMeta(s.meta, a.meta) ? s : { ...s, meta: a.meta, hydrateError: undefined };
case "runtime_snapshot": {
const runtimeStateSnapshot = acceptSessionRuntimeSnapshot(s.runtimeStateSnapshot, a.snapshot);
if (runtimeStateSnapshot === s.runtimeStateSnapshot) return s;
const meta = runtimeStateSnapshot.todos !== undefined && s.meta
? { ...s.meta, runtimeStateSnapshot, canonicalTodos: runtimeStateSnapshot.todos }
: s.meta;
return { ...s, meta, runtimeStateSnapshot };
}
case "context": {
const sessionTokens = typeof a.context.sessionTokens === "number"
? Math.max(0, a.context.sessionTokens)
@@ -2213,7 +2319,14 @@ function reduceState(s: State, a: Action): State {
}
case "local_notice": return { ...s, running: a.preserveRuntime || s.transcriptProtocol === 2 ? s.running : false, turnActive: a.preserveRuntime || s.transcriptProtocol === 2 ? s.turnActive : false, seq: s.seq + 1, items: [...s.items, { kind: "notice", id: `n${s.seq}`, local: true, level: a.level, text: a.text }] };
case "clearApproval": {
const next = { ...s, approval: undefined, pendingPrompt: Boolean(s.ask || s.mcpInteraction), resolvedPromptId: s.approval?.id ?? s.resolvedPromptId };
if (a.target && !stateOwnsInteraction(s, a.target)) return s;
const next = {
...s,
approval: undefined,
pendingPrompt: Boolean(s.ask || s.mcpInteraction),
resolvedPromptId: s.approval?.id ?? s.resolvedPromptId,
resolvedPromptKey: a.target?.instanceKey ?? s.resolvedPromptKey,
};
return endPromptWaitIfIdle(next);
}
case "clearAsk": {
@@ -2228,18 +2341,21 @@ function reduceState(s: State, a: Action): State {
}
case "expire_prompt": {
if (s.promptEpoch !== a.epoch) return s;
if (a.kind === "approval") {
if (s.approval?.id !== a.id) return s;
return endPromptWaitIfIdle({ ...s, approval: undefined, pendingPrompt: Boolean(s.ask || s.mcpInteraction), resolvedPromptId: a.id });
if (!stateOwnsInteraction(s, a.target)) return s;
if (a.target.kind === "approval" || a.target.kind === "plan" || a.target.kind === "recovery") {
return endPromptWaitIfIdle({ ...s, approval: undefined, pendingPrompt: Boolean(s.ask || s.mcpInteraction), resolvedPromptId: a.target.promptId, resolvedPromptKey: a.target.instanceKey });
}
if (a.kind === "ask") {
if (s.ask?.id !== a.id) return s;
return endPromptWaitIfIdle({ ...s, ask: undefined, pendingPrompt: Boolean(s.approval || s.mcpInteraction), resolvedPromptId: a.id });
if (a.target.kind === "ask") {
return endPromptWaitIfIdle({ ...s, ask: undefined, pendingPrompt: Boolean(s.approval || s.mcpInteraction), resolvedPromptId: a.target.promptId, resolvedPromptKey: a.target.instanceKey });
}
if (s.mcpInteraction?.id !== a.id) return s;
return endPromptWaitIfIdle({ ...s, mcpInteraction: undefined, pendingPrompt: Boolean(s.approval || s.ask), resolvedPromptId: a.id });
return endPromptWaitIfIdle({ ...s, mcpInteraction: undefined, pendingPrompt: Boolean(s.approval || s.ask), resolvedPromptId: a.target.promptId, resolvedPromptKey: a.target.instanceKey });
}
case "clearExtensionForm": {
if (!s.extensionForm) return s;
if (a.identity && (s.extensionForm.pluginId !== a.identity.pluginId || s.extensionForm.surfaceId !== a.identity.surfaceId ||
s.extensionForm.formInstanceId !== a.identity.formInstanceId)) return s;
return { ...s, extensionForm: undefined };
}
case "clearExtensionForm": return s.extensionForm ? { ...s, extensionForm: undefined } : s;
case "extension_notifications_drained": return s.extensionNotifications.length > 0 ? { ...s, extensionNotifications: [] } : s;
// A tool-approval posture switch auto-allowed exactly these prompt ids on
// the backend. Hide + tombstone the visible approval only when it is one
@@ -2253,8 +2369,8 @@ function reduceState(s: State, a: Action): State {
return endPromptWaitIfIdle(next);
}
case "ask_submit_succeeded": {
if (s.promptEpoch !== a.epoch || s.ask?.id !== a.id) return s;
const next = { ...s, ask: undefined, pendingPrompt: Boolean(s.approval || s.mcpInteraction), resolvedPromptId: a.id };
if (s.promptEpoch !== a.epoch || !stateOwnsInteraction(s, a.target)) return s;
const next = { ...s, ask: undefined, pendingPrompt: Boolean(s.approval || s.mcpInteraction), resolvedPromptId: a.target.promptId, resolvedPromptKey: a.target.instanceKey };
return endPromptWaitIfIdle(next);
}
// The optimistic clearApproval/clearAsk tombstone was wrong: the backend
@@ -2266,7 +2382,9 @@ function reduceState(s: State, a: Action): State {
// prompt, and a late failure from the old controller must not erase the
// new controller's tombstone.
case "submit_prompt_failed":
return s.resolvedPromptId === a.id && s.promptEpoch === a.epoch ? { ...s, resolvedPromptId: undefined } : s;
return s.resolvedPromptKey === a.target.instanceKey && s.promptEpoch === a.epoch
? { ...s, resolvedPromptId: undefined, resolvedPromptKey: undefined }
: s;
// A controller rebuild (model/effort/token-mode switch) replaces the
// backend controller in place and its approval/ask ids restart from "1"
// (per-controller counters, see sound.ts). Any id-anchored bookkeeping
@@ -2287,6 +2405,7 @@ function reduceState(s: State, a: Action): State {
promptEpoch: s.promptEpoch + 1,
pendingSubmissionId: undefined,
resolvedPromptId: undefined,
resolvedPromptKey: undefined,
promptArrivedId: undefined,
promptArrivedAt: undefined,
extensionStatuses: {},
@@ -2367,7 +2486,6 @@ export function useController() {
// can schedule an authoritative refetch after it rejects a stale snapshot.
const scheduleStalePromptReconcileRef = useRef<(tabId: string) => void>(() => {});
const [activeTabId, setActiveTabId] = useState<string | undefined>();
const runtimeState = useRuntimeSession(activeTabId);
const activeTabIdRef = useRef<string | undefined>(undefined);
// Invalidates async navigation completions even for ABA switches where the
// visible tab ID eventually returns to the original value.
@@ -2451,6 +2569,7 @@ export function useController() {
// The active tab's current state, with a stable identity for cancel().
const activeState = activeTabId ? getOrCreateState(statesRef.current, activeTabId) : initialState;
const runtimeState = useRuntimeSession(activeTabId, activeState.meta);
const stateRef = useRef(activeState);
const backendActiveTabIdRef = useRef<string | undefined>(undefined);
const backendActivationPromises = useRef(new Map<string, Promise<boolean>>());
@@ -2496,6 +2615,10 @@ export function useController() {
if (!streamDeltaOnly) bump();
}
}, [bump, notifyLiveListeners]);
useEffect(() => {
if (!activeTabId || !runtimeState.known || !runtimeState.state) return;
dispatchTo(activeTabId, { type: "runtime_snapshot", snapshot: runtimeState.state });
}, [activeTabId, dispatchTo, runtimeState.known, runtimeState.state]);
const clearBalanceForTab = useCallback((tabId: string): void => {
invalidateSharedQuery("BalanceForTab", [tabId]);
invalidateSharedQuery("MetaForTab", [tabId]);
@@ -3725,9 +3848,9 @@ export function useController() {
// Extension form dismissed/submitted locally: hide the surface. The backend
// round-trip (SubmitExtensionForm) lives in App.tsx, which owns the toast
// context used for error reporting.
const dismissExtensionForm = useCallback(() => {
if (!activeTabId) return;
dispatchTo(activeTabId, { type: "clearExtensionForm" });
const dismissExtensionForm = useCallback((tabId = activeTabId, identity?: Pick<ExtensionFormState, "pluginId" | "surfaceId" | "formInstanceId">) => {
if (!tabId) return;
dispatchTo(tabId, { type: "clearExtensionForm", identity });
}, [activeTabId, dispatchTo]);
// The App drained the queued extension notifications into the toast system.
@@ -3776,65 +3899,60 @@ export function useController() {
return cancelForTab(tabId, inboxItemIDs);
}, [activeTabId, cancelForTab]);
const isPromptCurrentForTab = useCallback((tabId: string, kind: "approval" | "ask" | "mcpInteraction", id: string) => (
statesRef.current.get(tabId)?.[kind]?.id === id
), []);
const approveForTab = useCallback((tabId: string, id: string, allow: boolean, session: boolean, persist: boolean) => {
if (!tabId) return;
const promptState = statesRef.current.get(tabId);
// Pin the failure callback to the prompt-id epoch the RPC was issued in:
// if a controller rebuild lands while the call is in flight, a late
// failure must not undo bookkeeping the NEW controller wrote for the same
// numeric id (#6432 round 4).
const epoch = statesRef.current.get(tabId)?.promptEpoch ?? 0;
dispatchTo(tabId, { type: "clearApproval" });
resolvePromptForTab(app, tabId, id, "approval", {
const isPromptCurrentForTab = useCallback((target: InteractionTarget) => {
const state = statesRef.current.get(target.tabId);
return Boolean(state && stateOwnsInteraction(state, target));
}, []);
const approveForTab = useCallback((target: InteractionTarget, allow: boolean, session: boolean, persist: boolean) => {
if (!target.tabId) return;
const promptState = statesRef.current.get(target.tabId);
const epoch = promptState?.promptEpoch ?? 0;
dispatchTo(target.tabId, { type: "clearApproval", target });
resolvePromptForSession(target, {
allow,
session,
persist,
generation: promptState?.approval?.generation,
permissionRevision: promptState?.approval?.permissionRevision,
}, promptState?.approval?.turnId ?? promptState?.activeTurnId, promptState?.approval?.runtimeEpoch ?? runtimeEpochByTabRef.current.get(tabId)).catch((error) => handlePromptFailure(dispatchTo, tabId, id, epoch, error, "approval"));
generation: target.requestGeneration,
permissionRevision: target.permissionRevision,
}).catch((error) => handlePromptFailure(dispatchTo, target, epoch, error));
}, [dispatchTo]);
const approve = useCallback((id: string, allow: boolean, session: boolean, persist: boolean) => {
if (activeTabId) approveForTab(activeTabId, id, allow, session, persist);
if (activeTabId) approveForTab(interactionTargetFromState(activeTabId, statesRef.current.get(activeTabId), "approval", id), allow, session, persist);
}, [activeTabId, approveForTab]);
const resolvePlanDecisionForTab = useCallback((tabId: string, id: string, action: "start_execution" | "revise_plan" | "exit_plan") => {
if (!tabId) return;
const promptState = statesRef.current.get(tabId);
const epoch = statesRef.current.get(tabId)?.promptEpoch ?? 0;
dispatchTo(tabId, { type: "clearApproval" });
resolvePromptForTab(app, tabId, id, "plan", { action }, promptState?.approval?.turnId ?? promptState?.activeTurnId, promptState?.approval?.runtimeEpoch ?? runtimeEpochByTabRef.current.get(tabId)).catch((error) => handlePromptFailure(dispatchTo, tabId, id, epoch, error, "approval"));
const resolvePlanDecisionForTab = useCallback((target: InteractionTarget, action: "start_execution" | "revise_plan" | "exit_plan") => {
if (!target.tabId) return;
const epoch = statesRef.current.get(target.tabId)?.promptEpoch ?? 0;
dispatchTo(target.tabId, { type: "clearApproval", target });
resolvePromptForSession(target, { action }).catch((error) => handlePromptFailure(dispatchTo, target, epoch, error));
}, [dispatchTo]);
const resolvePlanDecision = useCallback((id: string, action: "start_execution" | "revise_plan" | "exit_plan") => {
if (activeTabId) resolvePlanDecisionForTab(activeTabId, id, action);
if (activeTabId) resolvePlanDecisionForTab(interactionTargetFromState(activeTabId, statesRef.current.get(activeTabId), "plan", id), action);
}, [activeTabId, resolvePlanDecisionForTab]);
const resolveRecoveryForTab = useCallback((tabId: string, id: string, action: "continue" | "continue_task" | "revise" | "stop", feedback = "") => {
if (!tabId) return;
const promptState = statesRef.current.get(tabId);
const epoch = statesRef.current.get(tabId)?.promptEpoch ?? 0;
dispatchTo(tabId, { type: "clearApproval" });
resolvePromptForTab(app, tabId, id, "recovery", { action, feedback }, promptState?.approval?.turnId ?? promptState?.activeTurnId, promptState?.approval?.runtimeEpoch ?? runtimeEpochByTabRef.current.get(tabId)).catch((error) => handlePromptFailure(dispatchTo, tabId, id, epoch, error, "approval"));
const resolveRecoveryForTab = useCallback((target: InteractionTarget, action: "continue" | "continue_task" | "revise" | "stop", feedback = "") => {
if (!target.tabId) return;
const epoch = statesRef.current.get(target.tabId)?.promptEpoch ?? 0;
dispatchTo(target.tabId, { type: "clearApproval", target });
resolvePromptForSession(target, { action, feedback }).catch((error) => handlePromptFailure(dispatchTo, target, epoch, error));
}, [dispatchTo]);
const resolveRecovery = useCallback((id: string, action: "continue" | "continue_task" | "revise" | "stop", feedback = "") => {
if (activeTabId) resolveRecoveryForTab(activeTabId, id, action, feedback);
if (activeTabId) resolveRecoveryForTab(interactionTargetFromState(activeTabId, statesRef.current.get(activeTabId), "recovery", id), action, feedback);
}, [activeTabId, resolveRecoveryForTab]);
const answerQuestionForTab = useCallback((tabId: string, id: string, answers: QuestionAnswer[]): Promise<void> => {
if (!tabId) return Promise.reject(new Error("source tab is unavailable"));
const state = statesRef.current.get(tabId);
const answerQuestionForTab = useCallback((target: InteractionTarget, answers: QuestionAnswer[]): Promise<void> => {
if (!target.tabId) return Promise.reject(new Error("source tab is unavailable"));
const state = statesRef.current.get(target.tabId);
const epoch = state?.promptEpoch ?? 0;
return answerPromptForActiveTurn(app, tabId, id, answers, state?.ask?.turnId ?? state?.activeTurnId, state?.ask?.runtimeEpoch ?? runtimeEpochByTabRef.current.get(tabId)).then(
() => dispatchTo(tabId, { type: "ask_submit_succeeded", id, epoch }),
return resolvePromptForSession(target, { questions: answers }).then(
() => dispatchTo(target.tabId, { type: "ask_submit_succeeded", target, epoch }),
(error) => {
if (isStalePromptError(error)) dispatchTo(tabId, { type: "expire_prompt", id, epoch, kind: "ask" });
else dispatchTo(tabId, { type: "local_notice", level: "warn", text: t("notice.askSubmitFailed", { error: errorMessage(error) }), preserveRuntime: true });
void reconcileRuntimeAfterRejectedMutation(tabId);
if (isStalePromptError(error)) dispatchTo(target.tabId, { type: "expire_prompt", target, epoch });
else dispatchTo(target.tabId, { type: "local_notice", level: "warn", text: t("notice.askSubmitFailed", { error: errorMessage(error) }), preserveRuntime: true });
void reconcileRuntimeAfterRejectedMutation(target.tabId);
throw error;
},
);
@@ -3842,23 +3960,23 @@ export function useController() {
const answerQuestion = useCallback((id: string, answers: QuestionAnswer[]): Promise<void> => {
if (!activeTabId) return Promise.reject(new Error("active tab is unavailable"));
return answerQuestionForTab(activeTabId, id, answers);
return answerQuestionForTab(interactionTargetFromState(activeTabId, statesRef.current.get(activeTabId), "ask", id), answers);
}, [activeTabId, answerQuestionForTab]);
const answerMCPInteractionForTab = useCallback(
(tabId: string, id: string, action: "accept" | "decline" | "cancel", content?: Record<string, unknown>) => {
if (!tabId) return;
const promptState = statesRef.current.get(tabId);
(target: InteractionTarget, action: "accept" | "decline" | "cancel", content?: Record<string, unknown>) => {
if (!target.tabId) return;
const promptState = statesRef.current.get(target.tabId);
const epoch = promptState?.promptEpoch ?? 0;
dispatchTo(tabId, { type: "expire_prompt", id, epoch, kind: "mcp" });
resolvePromptForTab(app, tabId, id, "mcp", { action, content: content ?? null }, promptState?.mcpInteraction?.turnId ?? promptState?.activeTurnId, promptState?.mcpInteraction?.runtimeEpoch ?? runtimeEpochByTabRef.current.get(tabId)).catch((error) => handlePromptFailure(dispatchTo, tabId, id, epoch, error, "mcp"));
dispatchTo(target.tabId, { type: "expire_prompt", target, epoch });
resolvePromptForSession(target, { action, content: content ?? null }).catch((error) => handlePromptFailure(dispatchTo, target, epoch, error));
},
[dispatchTo],
);
const answerMCPInteraction = useCallback(
(id: string, action: "accept" | "decline" | "cancel", content?: Record<string, unknown>) => {
if (activeTabId) answerMCPInteractionForTab(activeTabId, id, action, content);
if (activeTabId) answerMCPInteractionForTab(interactionTargetFromState(activeTabId, statesRef.current.get(activeTabId), "mcp", id), action, content);
},
[activeTabId, answerMCPInteractionForTab],
);
@@ -4847,15 +4965,11 @@ export function useController() {
const projectedState = useMemo(() => {
if (!runtimeState.known) return activeState;
const runtimeTodos = runtimeState.state?.todos;
return {
...activeState,
running: activeState.transcriptProtocol === 2 ? activeState.running : runtimeState.running ?? activeState.running,
meta: runtimeTodos !== undefined && activeState.meta
? { ...activeState.meta, canonicalTodos: runtimeTodos }
: activeState.meta,
};
}, [activeState, runtimeState.known, runtimeState.running, runtimeState.state?.todos]);
}, [activeState, runtimeState.known, runtimeState.running]);
return {
state: projectedState,
liveStore,
+4 -3
View File
@@ -56,7 +56,7 @@ export interface RemoteSessionApi {
approve: (callId: string, decision: string) => Promise<void>;
resolvePlanDecision: (callId: string, action: "start_execution" | "revise_plan" | "exit_plan", feedback?: string) => Promise<void>;
answer: (callId: string, answers: RemoteAskAnswer[]) => Promise<void>;
clearExtensionForm: (pluginId: string, surfaceId: string) => void;
clearExtensionForm: (pluginId: string, surfaceId: string, formInstanceId?: string) => void;
rewind: (turn: number, scope: string) => Promise<void>;
/** Creates the child session for one turn; returns its id, or undefined with the reason in promptError. */
forkTurn: (target: ForkTargetView) => Promise<{ sessionId: string; operationId: string } | undefined>;
@@ -414,8 +414,9 @@ export function useRemoteSession(tabId: string | undefined, initial?: RemoteTabS
}
}, [tabId]);
const clearExtensionForm = useCallback((pluginId: string, surfaceId: string) => {
setTranscript((s) => s.extensionForm?.pluginId === pluginId && s.extensionForm.surfaceId === surfaceId
const clearExtensionForm = useCallback((pluginId: string, surfaceId: string, formInstanceId?: string) => {
setTranscript((s) => s.extensionForm?.pluginId === pluginId && s.extensionForm.surfaceId === surfaceId &&
(!formInstanceId || s.extensionForm.formInstanceId === formInstanceId)
? reducer(s, { type: "clearExtensionForm" }) : s);
}, []);
+4 -3
View File
@@ -1,11 +1,12 @@
import { useEffect, useSyncExternalStore } from "react";
import { app } from "./bridge";
import { runtimeStateStore, selectRuntime } from "./runtimeStateStore";
import { runtimeStateStore, selectRuntime, selectRuntimeSession } from "./runtimeStateStore";
import type { SessionIdentity } from "./sessionIdentity";
export function useRuntimeSession(tabId?: string, sessionPath?: string) {
export function useRuntimeSession(tabId: string | undefined, identity: SessionIdentity | string | undefined) {
const snapshot = useSyncExternalStore(runtimeStateStore.subscribe, runtimeStateStore.getSnapshot);
const failed = useSyncExternalStore(runtimeStateStore.subscribe, runtimeStateStore.getFailed);
const session = snapshot?.sessions.find(session => session.open && session.tabId === tabId && (!sessionPath || session.sessionPath === sessionPath));
const session = selectRuntimeSession(snapshot, tabId, identity);
return selectRuntime(session, failed);
}
@@ -6403,6 +6403,21 @@
]
}
},
"ResolvePromptForSession": {
"domain": "desktop/decision_runtime_api.go",
"owner": "App.ResolvePromptForSession",
"sources": [
"desktop/decision_runtime_api.go"
],
"scope": {
"kind": "owner-inputs",
"resolver": "App.ResolvePromptForSession",
"inputs": [
"target",
"answer"
]
}
},
"ResolvePromptForTab": {
"domain": "desktop/decision_runtime_api.go",
"owner": "App.ResolvePromptForTab",
@@ -6523,6 +6538,21 @@
]
}
},
"ResolveRemoteTabPromptExact": {
"domain": "desktop/remote_tab.go",
"owner": "App.ResolveRemoteTabPromptExact",
"sources": [
"desktop/remote_tab.go"
],
"scope": {
"kind": "owner-inputs",
"resolver": "App.ResolveRemoteTabPromptExact",
"inputs": [
"target",
"answer"
]
}
},
"ResolveRemoteWorkspacePathForTab": {
"domain": "desktop/remote_app.go",
"owner": "App.ResolveRemoteWorkspacePathForTab",
@@ -9624,6 +9654,21 @@
]
}
},
"SubmitExtensionFormExact": {
"domain": "desktop/extensions_app.go",
"owner": "App.SubmitExtensionFormExact",
"sources": [
"desktop/extensions_app.go"
],
"scope": {
"kind": "owner-inputs",
"resolver": "App.SubmitExtensionFormExact",
"inputs": [
"target",
"values"
]
}
},
"SubmitInitialGoalToTab": {
"domain": "desktop/app.go",
"owner": "App.SubmitInitialGoalToTab",
@@ -9732,6 +9777,21 @@
]
}
},
"SubmitRemoteTabExtensionFormExact": {
"domain": "desktop/remote_tab.go",
"owner": "App.SubmitRemoteTabExtensionFormExact",
"sources": [
"desktop/remote_tab.go"
],
"scope": {
"kind": "owner-inputs",
"resolver": "App.SubmitRemoteTabExtensionFormExact",
"inputs": [
"target",
"values"
]
}
},
"SubmitRemoteTabWithSubmission": {
"domain": "desktop/remote_tab.go",
"owner": "App.SubmitRemoteTabWithSubmission",
+2
View File
@@ -207,6 +207,8 @@ const serveCapabilitySessionContentV1 = "session-content-v1"
const serveCapabilitySessionReadV2 = "session-read-v2"
const serveCapabilityHistoryWindowV1 = "history-window-v1"
const serveCapabilityExtensionFormInstanceV1 = "extension-form-instance-v1"
const serveCapabilityInteractionTargetV1 = "interaction-target-v1"
const serveCapabilitySessionIdentityV1 = "session-identity-v1"
const serveCapabilitySessionOwnershipV1 = "session-ownership-v1"
const serveCapabilityGoalLifecycleV2 = servecontract.GoalLifecycleV2
+27 -24
View File
@@ -556,33 +556,36 @@ func remoteTabMetaLocked(tab *remoteTab) TabMeta {
}
ref := tab.ref
return TabMeta{
ID: tab.id,
Scope: "project",
WorkspaceRoot: tab.ref.Workspace,
WorkspaceName: remoteWorkspaceName(tab.ref.Workspace),
TopicID: remoteTabTopicID(tab),
TopicTitle: tab.topicTitle,
SessionPath: tab.session.path,
SessionID: tab.session.sessionID,
Label: label,
Mode: "normal",
Active: true,
Cwd: tab.ref.Workspace,
Remote: &ref,
RemoteState: tab.state,
ID: tab.id,
Scope: "project",
WorkspaceRoot: tab.ref.Workspace,
WorkspaceName: remoteWorkspaceName(tab.ref.Workspace),
TopicID: remoteTabTopicID(tab),
TopicTitle: tab.topicTitle,
SessionPath: tab.session.path,
SessionID: tab.session.sessionID,
SessionGeneration: tab.gen,
Label: label,
Mode: "normal",
Active: true,
Cwd: tab.ref.Workspace,
Remote: &ref,
RemoteState: tab.state,
// Every remote tab's view is built here, so the capability answer cannot
// be forgotten on one path. Local tabs never reach this helper and keep
// the false zero value.
ForkTargetsSupported: remoteForkTargetsSupported(tab),
Ready: tab.state == "ready",
Running: tab.runtime.running || tab.runtime.pendingPrompt || tab.runtime.backgroundJobs > 0,
TurnStartedAt: tab.runtime.turnStartedAt,
PendingPrompt: tab.runtime.pendingPrompt,
BackgroundJobs: tab.runtime.backgroundJobs,
CancelRequested: tab.runtime.cancelRequested,
Cancellable: tab.runtime.cancellable,
ReadOnly: tab.session.takenOver,
TakenOver: tab.session.takenOver,
ForkTargetsSupported: remoteForkTargetsSupported(tab),
InteractionTargetSupported: tab.capabilities[serveCapabilityInteractionTargetV1],
ExtensionFormInstanceSupported: tab.capabilities[serveCapabilityExtensionFormInstanceV1],
Ready: tab.state == "ready",
Running: tab.runtime.running || tab.runtime.pendingPrompt || tab.runtime.backgroundJobs > 0,
TurnStartedAt: tab.runtime.turnStartedAt,
PendingPrompt: tab.runtime.pendingPrompt,
BackgroundJobs: tab.runtime.backgroundJobs,
CancelRequested: tab.runtime.cancelRequested,
Cancellable: tab.runtime.cancellable,
ReadOnly: tab.session.takenOver,
TakenOver: tab.session.takenOver,
}
}
+47
View File
@@ -869,6 +869,31 @@ func (a *App) AnswerRemoteTab(tabID, callID string, answers []RemoteAskAnswer) e
return nil
}
func (a *App) ResolveRemoteTabPromptExact(target RemotePromptTarget, answer PromptAnswerView) error {
a.remoteTabMu.Lock()
tab := a.remoteTabs[target.TabID]
supported := tab != nil && tab.capabilities[serveCapabilityInteractionTargetV1]
current := tab != nil && tab.ref.HostID == target.HostID && tab.session.sessionID == target.SessionID && tab.gen == target.SessionGeneration
a.remoteTabMu.Unlock()
if !supported {
return fmt.Errorf("this remote Reasonix Serve does not support %s; upgrade it to answer this card safely", serveCapabilityInteractionTargetV1)
}
if !current {
return fmt.Errorf("remote prompt session binding is stale")
}
if strings.TrimSpace(target.PromptID) == "" || strings.TrimSpace(target.TurnID) == "" || strings.TrimSpace(target.Kind) == "" {
return fmt.Errorf("exact remote prompt identity is required")
}
if err := a.remoteTabPost(target.TabID, "/resolve-prompt", map[string]any{
"sessionId": target.SessionID, "promptId": target.PromptID, "turnId": target.TurnID, "runtimeEpoch": target.RuntimeEpoch,
"kind": target.Kind, "answer": answer,
}); err != nil {
return err
}
a.clearRemotePendingPromptExact(target)
return nil
}
func (a *App) SubmitRemoteTabExtensionForm(tabID, pluginID, surfaceID string, values map[string]any) error {
if err := a.remoteTabPost(tabID, "/extension-form", map[string]any{
"pluginId": pluginID, "surfaceId": surfaceID, "values": values,
@@ -879,6 +904,28 @@ func (a *App) SubmitRemoteTabExtensionForm(tabID, pluginID, surfaceID string, va
return nil
}
func (a *App) SubmitRemoteTabExtensionFormExact(target ExtensionFormTarget, values map[string]any) error {
a.remoteTabMu.Lock()
tab := a.remoteTabs[target.TabID]
supported := tab != nil && tab.capabilities[serveCapabilityExtensionFormInstanceV1]
current := tab != nil && tab.ref.HostID == target.HostID && tab.session.sessionID == target.SessionID && tab.gen == target.SessionGeneration
a.remoteTabMu.Unlock()
if !supported {
return fmt.Errorf("this remote Reasonix Serve does not support %s; upgrade it to submit this extension form safely", serveCapabilityExtensionFormInstanceV1)
}
if !current {
return fmt.Errorf("remote extension form binding is stale")
}
if err := a.remoteTabPost(target.TabID, "/extension-form", map[string]any{
"sessionId": target.SessionID, "pluginId": target.PluginID, "surfaceId": target.SurfaceID, "generation": target.PluginGeneration,
"formInstanceId": target.FormInstanceID, "values": values,
}); err != nil {
return err
}
a.clearRemotePendingExtensionFormExact(target.TabID, target.PluginID, target.SurfaceID, target.FormInstanceID)
return nil
}
// RewindRemoteTab rewinds to a checkpoint. Serve identifies checkpoints by
// TURN index and takes {turn, scope}; the checkpointID string is that turn.
func (a *App) RewindRemoteTab(tabID, checkpointID, scope string) error {
+30
View File
@@ -79,3 +79,33 @@ func (a *App) clearRemotePendingExtensionForm(tabID, pluginID, surfaceID string)
a.emitRemoteEvent("remote-tab:updated", meta)
}
}
func (a *App) clearRemotePendingExtensionFormExact(tabID, pluginID, surfaceID, formInstanceID string) {
a.remoteTabMu.Lock()
var meta TabMeta
changed := false
if tab := a.remoteTabs[tabID]; tab != nil {
frame := tab.pendingEvents[remotePendingExtensionFormKey]
var probe struct {
Extension *struct {
PluginID string `json:"pluginId"`
SurfaceID string `json:"surfaceId"`
FormInstanceID string `json:"formInstanceId"`
} `json:"extension"`
}
if json.Unmarshal(frame, &probe) == nil && probe.Extension != nil &&
probe.Extension.PluginID == pluginID && probe.Extension.SurfaceID == surfaceID &&
probe.Extension.FormInstanceID == formInstanceID {
tab.runtime.revision++
delete(tab.pendingEvents, remotePendingExtensionFormKey)
pending := len(tab.pendingEvents) > 0
changed = tab.runtime.pendingPrompt != pending
tab.runtime.pendingPrompt = pending
meta = remoteTabMetaLocked(tab)
}
}
a.remoteTabMu.Unlock()
if changed {
a.emitRemoteEvent("remote-tab:updated", meta)
}
}
+1 -1
View File
@@ -44,7 +44,7 @@ func (a *App) publishRemoteTabFrameForRouteLocked(tabID string, tab, expectedTab
case "turn_started":
a.recordRemoteTabTurnStarted(tabID, gen, frame)
refreshRuntime = true
case "approval_request", "ask_request":
case "approval_request", "ask_request", "mcp_interaction":
a.cacheRemotePendingEvent(tabID, gen, kind, frame)
refreshRuntime = true
case "extension_surface":
+79
View File
@@ -0,0 +1,79 @@
package main
import (
"encoding/json"
"strings"
)
func remotePromptCacheKind(kind string) string {
switch kind {
case "ask":
return "ask_request"
case "mcp":
return "mcp_interaction"
case "approval", "plan", "recovery":
return "approval_request"
default:
return ""
}
}
func pendingFrameMatchesTarget(frame json.RawMessage, target RemotePromptTarget) bool {
var probe struct {
RuntimeEpoch string `json:"runtimeEpoch"`
Approval *struct {
ID, TurnID string
} `json:"approval"`
Ask *struct {
ID, TurnID string
} `json:"ask"`
MCPInteraction *struct {
ID, TurnID string
} `json:"mcpInteraction"`
}
if json.Unmarshal(frame, &probe) != nil {
return false
}
id, turnID := "", ""
switch target.Kind {
case "ask":
if probe.Ask != nil {
id, turnID = probe.Ask.ID, probe.Ask.TurnID
}
case "mcp":
if probe.MCPInteraction != nil {
id, turnID = probe.MCPInteraction.ID, probe.MCPInteraction.TurnID
}
default:
if probe.Approval != nil {
id, turnID = probe.Approval.ID, probe.Approval.TurnID
}
}
return id == target.PromptID && turnID == target.TurnID && probe.RuntimeEpoch == target.RuntimeEpoch
}
func (a *App) clearRemotePendingPromptExact(target RemotePromptTarget) {
cacheKind := remotePromptCacheKind(strings.TrimSpace(target.Kind))
if cacheKind == "" {
return
}
a.remoteTabMu.Lock()
var meta TabMeta
changed := false
if tab := a.remoteTabs[target.TabID]; tab != nil && tab.ref.HostID == target.HostID &&
tab.session.sessionID == target.SessionID && tab.gen == target.SessionGeneration {
key := cacheKind + ":" + strings.TrimSpace(target.PromptID)
if frame := tab.pendingEvents[key]; pendingFrameMatchesTarget(frame, target) {
delete(tab.pendingEvents, key)
tab.runtime.revision++
pending := len(tab.pendingEvents) > 0
changed = tab.runtime.pendingPrompt != pending
tab.runtime.pendingPrompt = pending
meta = remoteTabMetaLocked(tab)
}
}
a.remoteTabMu.Unlock()
if changed {
a.emitRemoteEvent("remote-tab:updated", meta)
}
}
+37
View File
@@ -0,0 +1,37 @@
package main
import (
"encoding/json"
"testing"
)
func TestClearRemotePendingPromptExactPreservesReplacement(t *testing.T) {
tab := &remoteTab{
id: "remote-1", ref: RemoteTabRef{HostID: "host-a"}, gen: 7,
session: remoteTabSessionState{sessionID: "session-a"},
pendingEvents: map[string]json.RawMessage{
"ask_request:1": json.RawMessage(`{"kind":"ask_request","runtimeEpoch":"runtime-new","ask":{"id":"1","turnId":"turn-new","questions":[]}}`),
},
runtime: remoteTabRuntimeState{pendingPrompt: true},
}
a := &App{remoteTabs: map[string]*remoteTab{tab.id: tab}, remoteEventHook: func(string, any) {}}
old := RemotePromptTarget{TabID: tab.id, HostID: "host-a", SessionID: "session-a", SessionGeneration: 7,
PromptID: "1", TurnID: "turn-old", RuntimeEpoch: "runtime-old", Kind: "ask"}
a.clearRemotePendingPromptExact(old)
if len(tab.pendingEvents) != 1 || !tab.runtime.pendingPrompt {
t.Fatalf("old completion removed replacement: pending=%d runtime=%+v", len(tab.pendingEvents), tab.runtime)
}
current := old
current.TurnID, current.RuntimeEpoch = "turn-new", "runtime-new"
a.clearRemotePendingPromptExact(current)
if len(tab.pendingEvents) != 0 || tab.runtime.pendingPrompt {
t.Fatalf("current completion did not clear its request: pending=%d runtime=%+v", len(tab.pendingEvents), tab.runtime)
}
}
func TestRemotePendingEventKeyIncludesMCPIdentity(t *testing.T) {
frame := json.RawMessage(`{"kind":"mcp_interaction","mcpInteraction":{"id":"elicitation-1","turnId":"turn-1"}}`)
if got := remotePendingEventKey("mcp_interaction", frame); got != "mcp_interaction:elicitation-1" {
t.Fatalf("pending MCP key = %q", got)
}
}
+6 -1
View File
@@ -262,6 +262,9 @@ func remotePendingEventKey(kind string, frame json.RawMessage) string {
Ask *struct {
ID string `json:"id"`
} `json:"ask"`
MCPInteraction *struct {
ID string `json:"id"`
} `json:"mcpInteraction"`
}
_ = json.Unmarshal(frame, &probe)
id := ""
@@ -269,6 +272,8 @@ func remotePendingEventKey(kind string, frame json.RawMessage) string {
id = probe.Approval.ID
} else if probe.Ask != nil {
id = probe.Ask.ID
} else if probe.MCPInteraction != nil {
id = probe.MCPInteraction.ID
}
return kind + ":" + strings.TrimSpace(id)
}
@@ -280,7 +285,7 @@ func (a *App) bufferRemoteTabResumeFrame(tabID string, gen uint64, sessionPath,
}
key := ""
switch kind {
case "approval_request", "ask_request":
case "approval_request", "ask_request", "mcp_interaction":
key = remotePendingEventKey(kind, frame)
case "extension_surface":
if remotePendingExtensionForm(frame) {
+91 -6
View File
@@ -13,12 +13,29 @@ import (
// The gate pauses a real projection read after App bindings were copied. Its
// result may then belong to a controller whose session was rotated meanwhile.
type bindingRuntimeReader struct {
control.SessionAPI
mu sync.Mutex
state event.RuntimeStateSnapshot
entered chan struct{}
release chan struct{}
once sync.Once
stubSessionAPI
mu sync.Mutex
state event.RuntimeStateSnapshot
entered chan struct{}
release chan struct{}
once sync.Once
resolved []control.PromptIdentity
}
func (*bindingRuntimeReader) AutoApproveTools() bool { return false }
func (*bindingRuntimeReader) PlanMode() bool { return false }
func (*bindingRuntimeReader) Goal() string { return "" }
func (*bindingRuntimeReader) GoalStatus() string { return control.GoalStatusStopped }
func (*bindingRuntimeReader) GoalRuntime() control.GoalRuntimeView {
return control.GoalRuntimeView{}
}
func (*bindingRuntimeReader) ToolApprovalMode() string { return "ask" }
func (r *bindingRuntimeReader) ResolvePromptExact(identity control.PromptIdentity, _ control.PromptAnswer) error {
r.mu.Lock()
defer r.mu.Unlock()
r.resolved = append(r.resolved, identity)
return nil
}
func (r *bindingRuntimeReader) RuntimeStateSnapshot() event.RuntimeStateSnapshot {
@@ -61,6 +78,74 @@ func TestRuntimeStateProjectionDoesNotHoldMutexAcrossControllerRead(t *testing.T
}
}
func TestMetaForTabRevalidatesBindingAndReadsControllerUnlocked(t *testing.T) {
old := &bindingRuntimeReader{
state: event.RuntimeStateSnapshot{SchemaVersion: 1, ProjectionEpoch: "old-producer", RuntimeEpoch: "old-runtime", Revision: 5,
Phase: "executing", Running: true, Todos: []event.Todo{{Content: "old", Status: "in_progress"}}},
entered: make(chan struct{}), release: make(chan struct{}),
}
newState := event.RuntimeStateSnapshot{SchemaVersion: 1, ProjectionEpoch: "new-producer", RuntimeEpoch: "new-runtime", Revision: 1,
Phase: "idle", Todos: []event.Todo{{Content: "new", Status: "pending"}}}
next := &bindingRuntimeReader{state: newState}
tab := &WorkspaceTab{ID: "meta-binding", Scope: "global", WorkspaceRoot: "/tmp/meta-binding", SessionPath: "/old.jsonl", SessionID: "old-session", SessionGeneration: 1, Ctrl: old, Ready: true}
// Keep the test focused on the runtime sample. A cache miss schedules an
// unrelated metadata refresh which briefly takes App.mu and can make the
// lock assertion nondeterministic under the race detector.
tab.metaExtras.Store(&tabMetaExtras{controller: old, workspaceRoot: tab.WorkspaceRoot, fetchedAt: time.Now()})
a := &App{tabs: map[string]*WorkspaceTab{tab.ID: tab}, detachedSessions: map[string]*WorkspaceTab{}}
done := make(chan Meta, 1)
go func() { done <- a.MetaForTab(tab.ID) }()
select {
case <-old.entered:
case <-time.After(5 * time.Second):
t.Fatal("MetaForTab did not reach controller snapshot")
}
if !a.mu.TryLock() {
close(old.release)
t.Fatal("MetaForTab held App.mu while reading the controller")
}
tab.Ctrl = next
tab.SessionID = "new-session"
tab.SessionPath = "/new.jsonl"
tab.SessionGeneration = 2
a.mu.Unlock()
close(old.release)
select {
case got := <-done:
if got.SessionID != "new-session" || got.SessionGeneration != 2 || got.RuntimeStateSnapshot == nil ||
!reflect.DeepEqual(*got.RuntimeStateSnapshot, newState) || got.CanonicalTodos == nil || len(*got.CanonicalTodos) != 1 || (*got.CanonicalTodos)[0].Content != "new" {
t.Fatalf("MetaForTab paired stale identity and state: %+v", got)
}
case <-time.After(5 * time.Second):
t.Fatal("MetaForTab did not finish after controller replacement")
}
}
func TestResolvePromptForSessionRejectsStaleBindingBeforeController(t *testing.T) {
reader := &bindingRuntimeReader{}
tab := &WorkspaceTab{ID: "prompt", SessionID: "session-a", SessionGeneration: 3, Ctrl: reader}
a := &App{tabs: map[string]*WorkspaceTab{tab.ID: tab}}
target := InteractionTargetView{TabID: tab.ID, HostID: localDesktopHostID, SessionID: tab.SessionID,
SessionGeneration: 2, PromptID: "p1", TurnID: "t1", RuntimeEpoch: "r1", Kind: "ask"}
if err := a.ResolvePromptForSession(target, PromptAnswerView{}); err == nil {
t.Fatal("stale session generation reached prompt resolver")
}
reader.mu.Lock()
if len(reader.resolved) != 0 {
t.Fatalf("stale target called controller: %+v", reader.resolved)
}
reader.mu.Unlock()
target.SessionGeneration = 3
if err := a.ResolvePromptForSession(target, PromptAnswerView{}); err != nil {
t.Fatalf("current target rejected: %v", err)
}
reader.mu.Lock()
defer reader.mu.Unlock()
if len(reader.resolved) != 1 || reader.resolved[0].PromptID != "p1" {
t.Fatalf("current target calls = %+v", reader.resolved)
}
}
func TestLocalBindingUsesControllerIdentityNotMutableContents(t *testing.T) {
first, second := &bindingRuntimeReader{}, &bindingRuntimeReader{}
tab := &WorkspaceTab{ID: "identity"}
+24 -21
View File
@@ -1,6 +1,7 @@
package main
import (
"reasonix/internal/event"
"reasonix/internal/evidence"
goaldomain "reasonix/internal/goal"
"reasonix/internal/session"
@@ -8,27 +9,29 @@ import (
// Meta describes the session for the frontend's header and status line.
type Meta struct {
Label string `json:"label"`
Ready bool `json:"ready"`
Runtime SessionRuntimeView `json:"runtime"`
StartupErr string `json:"startupErr,omitempty"`
EventChannel string `json:"eventChannel"`
SessionPath string `json:"sessionPath,omitempty"`
SessionID string `json:"sessionId,omitempty"`
Session *session.SessionRef `json:"session,omitempty"`
SessionRevision int64 `json:"sessionRevision,omitempty"`
SessionDigest string `json:"sessionDigest,omitempty"`
Cwd string `json:"cwd"`
WorkspaceRoot string `json:"workspaceRoot,omitempty"`
WorkspaceName string `json:"workspaceName,omitempty"`
WorkspacePath string `json:"workspacePath,omitempty"`
GitBranch string `json:"gitBranch,omitempty"`
ImageInputEnabled bool `json:"imageInputEnabled"`
VisionFallbackEnabled bool `json:"visionFallbackEnabled,omitempty"`
AutoApproveTools bool `json:"autoApproveTools"`
Bypass bool `json:"bypass"` // legacy JSON key for YOLO/full-access tool auto-approval
CollaborationMode string `json:"collaborationMode"`
ToolApprovalMode string `json:"toolApprovalMode"`
Label string `json:"label"`
Ready bool `json:"ready"`
Runtime SessionRuntimeView `json:"runtime"`
StartupErr string `json:"startupErr,omitempty"`
EventChannel string `json:"eventChannel"`
SessionPath string `json:"sessionPath,omitempty"`
SessionID string `json:"sessionId,omitempty"`
Session *session.SessionRef `json:"session,omitempty"`
SessionRevision int64 `json:"sessionRevision,omitempty"`
SessionDigest string `json:"sessionDigest,omitempty"`
SessionGeneration uint64 `json:"sessionGeneration"`
RuntimeStateSnapshot *event.RuntimeStateSnapshot `json:"runtimeStateSnapshot,omitempty"`
Cwd string `json:"cwd"`
WorkspaceRoot string `json:"workspaceRoot,omitempty"`
WorkspaceName string `json:"workspaceName,omitempty"`
WorkspacePath string `json:"workspacePath,omitempty"`
GitBranch string `json:"gitBranch,omitempty"`
ImageInputEnabled bool `json:"imageInputEnabled"`
VisionFallbackEnabled bool `json:"visionFallbackEnabled,omitempty"`
AutoApproveTools bool `json:"autoApproveTools"`
Bypass bool `json:"bypass"` // legacy JSON key for YOLO/full-access tool auto-approval
CollaborationMode string `json:"collaborationMode"`
ToolApprovalMode string `json:"toolApprovalMode"`
// TokenMode and AgentPreset are deprecated dual-write wire values pinned to
// their safe defaults; one-version-old frontends still parse them.
TokenMode string `json:"tokenMode"`
+12 -10
View File
@@ -22,16 +22,18 @@ type TabMeta struct {
// ForkTargetsSupported reports whether this tab can fork a turn into an
// independent child session. False for a local tab and for a remote serve
// before its handshake; always emitted, so absence never means "unsupported".
ForkTargetsSupported bool `json:"forkTargetsSupported"`
TopicID string `json:"topicId"`
TopicTitle string `json:"topicTitle"`
SessionPath string `json:"sessionPath,omitempty"`
SessionID string `json:"sessionId,omitempty"`
Session *session.SessionRef `json:"session,omitempty"`
SessionRevision int64 `json:"sessionRevision,omitempty"`
SessionDigest string `json:"sessionDigest,omitempty"`
SessionGeneration uint64 `json:"sessionGeneration,omitempty"`
ReadOnly bool `json:"readOnly,omitempty"`
ForkTargetsSupported bool `json:"forkTargetsSupported"`
InteractionTargetSupported bool `json:"interactionTargetSupported"`
ExtensionFormInstanceSupported bool `json:"extensionFormInstanceSupported"`
TopicID string `json:"topicId"`
TopicTitle string `json:"topicTitle"`
SessionPath string `json:"sessionPath,omitempty"`
SessionID string `json:"sessionId,omitempty"`
Session *session.SessionRef `json:"session,omitempty"`
SessionRevision int64 `json:"sessionRevision,omitempty"`
SessionDigest string `json:"sessionDigest,omitempty"`
SessionGeneration uint64 `json:"sessionGeneration,omitempty"`
ReadOnly bool `json:"readOnly,omitempty"`
// TakenOver marks a local or remote tab spectating a session whose writer is
// on the other side of a cooperative handoff.
TakenOver bool `json:"takenOver,omitempty"`
+22 -20
View File
@@ -6990,6 +6990,7 @@ type tabRuntimeSnapshot struct {
workspaceRoot string
sessionPath string
sessionID string
sessionGeneration uint64
topicID string
topicTitle string
sharedHostKey string
@@ -7014,26 +7015,27 @@ func snapshotTabRuntimeLocked(tab *WorkspaceTab) tabRuntimeSnapshot {
return tabRuntimeSnapshot{}
}
return tabRuntimeSnapshot{
ctrl: tab.Ctrl,
sink: tab.sink,
label: tab.Label,
ready: tab.Ready,
readOnly: tab.ReadOnly,
startupErr: tab.StartupErr,
scope: tab.Scope,
workspaceRoot: tab.WorkspaceRoot,
sessionPath: tab.SessionPath,
sessionID: tab.SessionID,
topicID: tab.TopicID,
topicTitle: tab.TopicTitle,
sharedHostKey: tab.SharedHostKey,
model: tab.model,
effort: cloneStringPtr(tab.effort),
tokenMode: currentTabTokenMode(tab),
qualityFloor: tab.qualityFloor,
mode: tab.mode,
goal: tab.goal,
toolApprovalMode: tab.toolApprovalMode,
ctrl: tab.Ctrl,
sink: tab.sink,
label: tab.Label,
ready: tab.Ready,
readOnly: tab.ReadOnly,
startupErr: tab.StartupErr,
scope: tab.Scope,
workspaceRoot: tab.WorkspaceRoot,
sessionPath: tab.SessionPath,
sessionID: tab.SessionID,
sessionGeneration: tab.SessionGeneration,
topicID: tab.TopicID,
topicTitle: tab.TopicTitle,
sharedHostKey: tab.SharedHostKey,
model: tab.model,
effort: cloneStringPtr(tab.effort),
tokenMode: currentTabTokenMode(tab),
qualityFloor: tab.qualityFloor,
mode: tab.mode,
goal: tab.goal,
toolApprovalMode: tab.toolApprovalMode,
}
}
+27 -9
View File
@@ -1,15 +1,20 @@
# Desktop prompt identity
Desktop decision cards are owned by the controller that created them. Every
new prompt request carries a prompt id, the owning turn id, and the runtime
epoch visible to the tab. The kind identifies the decision surface: `ask`,
`approval`, `plan`, `recovery`, or `mcp`.
new prompt request carries the owning `hostId + sessionId`, session generation,
prompt id, turn id, runtime epoch and request kind. The kind identifies the
decision surface: `ask`, `approval`, `plan`, `recovery`, or `mcp`. The frontend
also derives an immutable request-instance key from this identity (plus approval
generation and permission revision where applicable) for component state and
Ask drafts.
The frontend submits these values through `ResolvePromptForTab`. The controller
checks the runtime epoch, active turn, prompt owner, and pending state under its
exact-resolution boundary before persisting `PromptAnswered` and waking the
original waiter. A stale turn or runtime is rejected without routing the answer
to a replacement controller. Failed persistence restores the prompt to its
The frontend submits the complete target through `ResolvePromptForSession`.
Desktop validates the tab's host, session and generation while fixing the
controller under the App lock, releases that lock, and then resolves the exact
prompt. The controller checks runtime epoch, active turn, prompt owner and
pending state before persisting `PromptAnswered` and waking the original waiter.
A stale binding, turn or runtime is rejected without routing the answer to a
replacement controller. Failed persistence restores only that request to its
pending state so the user can retry.
Prompt requests and lifecycle events expose `promptId`, `promptKind`, and
@@ -19,7 +24,20 @@ paths.
Older host methods such as `AnswerQuestionForTab`, `ApproveTab`, and
`ResolveRecoveryTab` remain available for older clients. New frontend code uses
`ResolvePromptForTab` and does not silently downgrade to an unfenced method.
`ResolvePromptForSession` and does not silently downgrade to an unfenced method.
When a stale response is received, the card is removed from the active decision
surface and one tab-scoped prompt replay is requested; only a new pending
identity can re-arm a card.
Extension forms use the same rule with `SubmitExtensionFormExact`. The host
assigns a `formInstanceId` to every publication, pins the validated sidecar
client before releasing the hub lock, and applies completion only to that form
instance. Re-publishing the same plugin surface creates a different instance,
so an older completion cannot close or revive the replacement.
Remote Serve advertises `interaction-target-v1` and
`extension-form-instance-v1` independently. Without the relevant capability,
the current client keeps the session readable but disables that card or form
and asks the user to upgrade; it never sends an unsafe legacy mutation. These
identities are transport and UI ownership fields and require no persisted
session migration.
+22 -9
View File
@@ -1,20 +1,33 @@
# Desktop 交互卡片身份
Desktop 决策卡片由创建它的 controller 所有。每个新的交互请求都携带
prompt ID、所属 turn ID,以及 tab 可见的 runtime epoch。`kind` 标识交互类型:
`ask`、`approval`、`plan`、`recovery` 或 `mcp`。
Desktop 决策卡片由创建它的 controller 所有。每个新的交互请求都携带所属
`hostId + sessionId`、会话 generation、prompt ID、turn ID、runtime epoch 和
请求类型。`kind` 标识 `ask`、`approval`、`plan`、`recovery` 或 `mcp`。前端还会
使用完整身份生成不可变的请求实例 key;审批请求会额外包含 approval generation
和 permission revision。组件局部状态及 Ask 草稿均按该实例 key 保存。
前端通过 `ResolvePromptForTab` 提交这些字段。controller 在 exact resolve
边界内校验 runtime epoch、active turn、prompt owner 和 pending 状态,然后
持久化 `PromptAnswered` 并唤醒原始等待方。旧 turn 或旧 runtime 的提交会被
拒绝,不会被路由到替换后的 controller。持久化失败时 prompt 会恢复为
pending,用户可以重试。
前端通过 `ResolvePromptForSession` 提交完整目标。Desktop 在 App 锁内校验
标签的 host、session 和 generation 并固定 controller,释放锁后再执行 exact
resolve。controller 校验 runtime epoch、active turn、prompt owner 和 pending
状态,然后持久化 `PromptAnswered` 并唤醒原始等待方。旧绑定、旧 turn 或旧
runtime 的提交会被拒绝,不会被路由到替换后的 controller。持久化失败时只把
原请求恢复为 pending,用户可以重试。
prompt 请求和生命周期事件会暴露 `promptId`、`promptKind` 和 `turnId`。
Desktop 事件 envelope 会携带 tab 的 runtime epoch。缺少 turn identity 的旧
事件会标记为 `promptLegacy`,只能通过兼容路径处理。
`AnswerQuestionForTab`、`ApproveTab` 和 `ResolveRecoveryTab` 等旧 host 方法
仍为旧客户端保留。新前端统一使用 `ResolvePromptForTab`,不会静默降级到
仍为旧客户端保留。新前端统一使用 `ResolvePromptForSession`,不会静默降级到
没有 fence 的方法。收到 stale 响应后,旧卡片会从当前决策面移除,并按 tab
请求一次 prompt replay;只有新的 pending identity 才能重新显示卡片。
扩展表单遵循同一原则,使用 `SubmitExtensionFormExact`。host 为每次发布生成
`formInstanceId`,在 hub 锁内固定通过校验的 sidecar client,释放锁后才调用
sidecar;完成结果只能修改该表单实例。相同插件 surface 再次发布会获得不同
实例,因此旧完成回调不能关闭或恢复替换后的表单。
远程 Serve 分别声明 `interaction-target-v1` 和
`extension-form-instance-v1`。缺少对应能力时,当前客户端仍允许查看会话,但会
禁用相关卡片或表单并提示升级,不会发送不安全的旧提交。这些字段只用于传输和
界面状态归属,不需要迁移持久化会话数据。
+5 -2
View File
@@ -23,7 +23,8 @@ Project activity includes every owned runtime and deduplicates the same remote s
`event.RuntimeStateSnapshot` schema version 1 explicitly includes:
- `runtimeEpoch` and `revision`: instance identity and monotonic version; one version has one immutable value.
- `projectionEpoch` and `revision`: snapshot-producer identity and a version that is monotonic only within that producer. One pair has one immutable value.
- `runtimeEpoch`: runtime and interaction-request identity. It is not a snapshot-producer version.
- `phase`: `idle`, `executing`, `finishing` or `closed`.
- `running`, `turnId`, `turnStatus`, `turnEventSeq`.
- `pendingPrompt`, `cancelRequested`, `cancellable`, `backgroundJobs`, `activity`.
@@ -32,6 +33,8 @@ The legacy `Running()` execution/finishing protection remains intact. Controller
Desktop `GetRuntimeStateSnapshot` and `runtime-state:changed` share a complete projection with epoch/revision, sessions and topics. Sampling reads controllers outside App locks and then revalidates the complete local binding set, including controller, tab, session generation, path and open/detached identity. The older project-tree interface adapts the same projection.
Tab metadata exposes `sessionGeneration` and the same controller `runtimeStateSnapshot`; its compatibility `canonicalTodos` value is derived from that one sample. A binding-validated metadata read establishes a new `projectionEpoch` baseline. Ordinary delayed runtime frames may advance `revision` within the active epoch, but cannot switch the producer. A versioned empty todo list is an authoritative clear; a missing snapshot means that no baseline has arrived. The frontend retains snapshots by `hostId + sessionId`, so switching tabs changes visibility without transferring or clearing another session's state.
Serve `GET /runtime-states` reads foreground and detached controllers from memory; `/status` adds `runtimeState`. Session-specific reads resolve the controller owning that session. SSE `runtime_state` uses existing session tagging and cannot publish the new session state before its `session_changed` barrier. External takeover and read-only mirrors retain existing ownership rules.
The remote GET/SSE reducer preserves generation, client, selection and route fences. Older versions are discarded, identical versions are no-ops, and conflicting versions trigger coalesced resynchronization. Adopting a new epoch requires an authoritative read. A late GET cannot overwrite newer SSE state or a replacement binding.
@@ -40,7 +43,7 @@ The remote GET/SSE reducer preserves generation, client, selection and route fen
The application subscribes before reading. Pushes apply immediately. One application owner checks every 30 seconds, with one read per Serve connection per pass. Attachment, focus and connection changes reconcile immediately; concurrent requests coalesce. Failures back off at 5, 10, 20 and 30 seconds without clearing known state.
With the new contract available, legacy per-session watchdogs no longer decide runtime truth. The ten-minute silence cleanup no longer determines project activity. A legacy Serve returning 404/501 is remembered for that connection generation and uses existing status interfaces. Missing fields are not zero values, and legacy payloads never imply finishing. Existing protocol fields remain supported.
With the new contract available, legacy per-session watchdogs no longer decide runtime truth. The ten-minute silence cleanup no longer determines project activity. A legacy Serve returning 404/501 is remembered for that connection generation and uses existing status interfaces. Legacy todo refreshes use one serialized authoritative query per binding; change notifications invalidate that query instead of merging incomparable sources. Missing fields are not zero values, and legacy payloads never imply finishing. Existing protocol fields remain supported. These additions do not change the persisted session format.
Diagnostics report source, anonymous epoch, revision, phase, synchronization reason and stale/conflict/failure counts only. They exclude per-token logs, prompts, credentials and full paths.
+5 -2
View File
@@ -23,7 +23,8 @@ Desktop、Serve 和远程会话使用控制器提交的运行状态快照。正
`event.RuntimeStateSnapshot` schema version 1 显式包含:
- `runtimeEpoch`、`revision`:运行实例代次和单调版本;同版本对应同内容。
- `projectionEpoch`、`revision`:快照生产者身份,以及仅在该生产者内单调递增的版本;同一组值对应同一内容。
- `runtimeEpoch`:运行实例和交互请求身份,不能作为快照生产者版本使用。
- `phase`:`idle`、`executing`、`finishing` 或 `closed`。
- `running`、`turnId`、`turnStatus`、`turnEventSeq`。
- `pendingPrompt`、`cancelRequested`、`cancellable`、`backgroundJobs`、`activity`。
@@ -32,6 +33,8 @@ Desktop、Serve 和远程会话使用控制器提交的运行状态快照。正
Desktop 的 `GetRuntimeStateSnapshot` 和 `runtime-state:changed` 使用相同完整投影,包含投影 epoch/revision、sessions 和 topics。本地采样离开 App 锁读取控制器后,复验标签、控制器、会话代次、路径及打开/分离身份。旧项目树接口由同一投影适配。
标签 metadata 暴露 `sessionGeneration` 和同一次 controller 采样得到的 `runtimeStateSnapshot`;兼容字段 `canonicalTodos` 也从该快照转换。通过绑定校验的 metadata 可以建立新的 `projectionEpoch` 基线;普通晚到 runtime 帧只能在当前 epoch 内推进 `revision`,不能替换生产者。有版本的空待办数组表示有效清空,缺失快照只表示尚未取得基线。前端按 `hostId + sessionId` 保存快照,因此切换标签只改变可见内容,不会转移或清空其他会话的状态。
Serve 的 `GET /runtime-states` 仅读取前台及 detached 控制器的内存快照;`/status` 增加 `runtimeState`。指定 session 时优先匹配真实拥有该 session 的实例。SSE 的 `runtime_state` 使用既有 session tagging,新的会话状态不会越过 `session_changed` 屏障。外部接管和只读镜像继续遵守原 ownership 规则。
远程 reducer 同时处理 GET/SSE,保留连接 generation、client、selection 和路由保护。旧版本被丢弃,同版本同内容为无操作,同版本冲突触发合并后的重同步。新 epoch 需要权威读取确认;晚到 GET 不能覆盖更新的 SSE 或新绑定。
@@ -40,7 +43,7 @@ Serve 的 `GET /runtime-states` 仅读取前台及 detached 控制器的内存
应用先订阅、后读快照。正常变化立即推送;应用级 owner 每 30 秒校验,每个 Serve 连接在一次校验中只读取一次。挂接、焦点恢复和连接变化触发即时校验,在途请求合并。失败按 5、10、20、30 秒退避;失败不清空已知状态。
新快照可用时,旧的逐会话运行 watchdog 不再判定运行真值,十分钟沉默清理也不再决定项目活动状态。旧 Serve 的 404/501 能力缺失在当前连接代次内记住,使用原有状态接口;缺失字段不作为零值,也不猜测收尾阶段。所有旧协议字段保留。
新快照可用时,旧的逐会话运行 watchdog 不再判定运行真值,十分钟沉默清理也不再决定项目活动状态。旧 Serve 的 404/501 能力缺失在当前连接代次内记住,使用原有状态接口。旧待办协议在一个绑定内只串行读取单一权威来源,变化通知只使该查询失效,不混合不可比较的来源。缺失字段不作为零值,也不猜测收尾阶段。所有旧协议字段保留;新增字段不改变持久化会话格式。
诊断仅记录状态来源、匿名代次、版本、阶段、同步原因及丢弃/冲突/失败计数;不逐 token 输出,不记录提示词、凭据或完整路径。
+17
View File
@@ -128,6 +128,23 @@ func (c *Controller) SubmitExtensionForm(ctx context.Context, pluginID, surfaceI
return nil
}
// SubmitExtensionFormExact validates the host-issued publication instance
// before the sidecar receives any values.
func (c *Controller) SubmitExtensionFormExact(ctx context.Context, pluginID, surfaceID string, generation uint64, formInstanceID string, values map[string]any) error {
h := c.extensionUIHub()
if h == nil {
return errors.New("no extension UI hub is installed (no extension runtimes started)")
}
result, err := h.SubmitExact(ctx, pluginID, surfaceID, h.SessionID(), generation, formInstanceID, values)
if err != nil {
return err
}
if !result.Accepted {
return fmt.Errorf("extension %s did not accept the submission for surface %s", pluginID, surfaceID)
}
return nil
}
// ParseExtensionActionArgs maps the trailing fields of a
// "/<plugin>:<action> args…" invocation onto the action's string map:
// key=value fields become named entries, bare fields land in positional
+2 -2
View File
@@ -125,8 +125,8 @@ func (c *Controller) refreshRuntimeStateAttempt(e event.Event, attempt int) {
ledger := c.turnEventLedger()
initialized := r.snapshot.SchemaVersion == 1
base, activity := r.snapshot, r.activity
if r.snapshot.RuntimeEpoch == "" || r.path != path || r.ledger != ledger {
base = event.RuntimeStateSnapshot{RuntimeEpoch: newRuntimeStateEpoch()}
if r.snapshot.ProjectionEpoch == "" || r.path != path || r.ledger != ledger {
base = event.RuntimeStateSnapshot{ProjectionEpoch: newRuntimeStateEpoch(), RuntimeEpoch: newRuntimeStateEpoch()}
activity = ""
}
next := base
+10 -9
View File
@@ -20,15 +20,16 @@ const (
// credential-redacted by the host UI hub before the event is emitted. Exactly
// one sub-struct is set, selected by Kind.
type ExtensionSurfacePayload struct {
PluginID string
SurfaceID string
SessionID string
Generation uint64
Kind string // status | card | form | notification (request reserved)
Status *ExtensionStatusView
Card *ExtensionCardView
Form *ExtensionFormView
Notification *ExtensionNotificationView
PluginID string
SurfaceID string
SessionID string
Generation uint64
FormInstanceID string
Kind string // status | card | form | notification (request reserved)
Status *ExtensionStatusView
Card *ExtensionCardView
Form *ExtensionFormView
Notification *ExtensionNotificationView
}
// ExtensionStatusView is a one-line status contribution (mirrors the
+1
View File
@@ -32,6 +32,7 @@ type RuntimeStateSnapshot struct {
HostID string `json:"hostId,omitempty"`
SessionID string `json:"sessionId,omitempty"`
SessionCodec string `json:"sessionCodec,omitempty"`
ProjectionEpoch string `json:"projectionEpoch"`
RuntimeEpoch string `json:"runtimeEpoch"`
ActivityRevision uint64 `json:"activityRevision"`
Revision uint64 `json:"revision"`
+11 -10
View File
@@ -605,15 +605,16 @@ type ContextMaintenance struct {
// ExtensionSurface is the JSON form of an event.ExtensionSurfacePayload.
type ExtensionSurface struct {
PluginID string `json:"pluginId"`
SurfaceID string `json:"surfaceId"`
SessionID string `json:"sessionId,omitempty"`
Generation uint64 `json:"generation,omitempty"`
Kind string `json:"kind"`
Status *ExtensionStatus `json:"status,omitempty"`
Card *ExtensionCard `json:"card,omitempty"`
Form *ExtensionForm `json:"form,omitempty"`
Notification *ExtensionNotification `json:"notification,omitempty"`
PluginID string `json:"pluginId"`
SurfaceID string `json:"surfaceId"`
SessionID string `json:"sessionId,omitempty"`
Generation uint64 `json:"generation,omitempty"`
FormInstanceID string `json:"formInstanceId,omitempty"`
Kind string `json:"kind"`
Status *ExtensionStatus `json:"status,omitempty"`
Card *ExtensionCard `json:"card,omitempty"`
Form *ExtensionForm `json:"form,omitempty"`
Notification *ExtensionNotification `json:"notification,omitempty"`
}
// ExtensionStatus is the JSON form of an event.ExtensionStatusView.
@@ -681,7 +682,7 @@ func ToWireExtensionSurface(p *event.ExtensionSurfacePayload) *ExtensionSurface
}
out := &ExtensionSurface{
PluginID: p.PluginID, SurfaceID: p.SurfaceID, SessionID: p.SessionID,
Generation: p.Generation, Kind: p.Kind,
Generation: p.Generation, FormInstanceID: p.FormInstanceID, Kind: p.Kind,
}
if s := p.Status; s != nil {
out.Status = &ExtensionStatus{Label: s.Label, Detail: s.Detail, Severity: s.Severity, Progress: s.Progress}
+110 -19
View File
@@ -95,19 +95,32 @@ type ActionView struct {
// its controller; BindGeneration re-binds it across a reload. The mutex makes
// every method safe for concurrent sidecar traffic.
type Hub struct {
mu sync.Mutex
mu sync.Mutex
sessionID string
generation uint64
owner *extension.RuntimeOwner
emit func(event.Event)
requestFn RequestFunc
warn func(string)
resolve ClientResolver
known map[string]bool
crashed map[string]bool
actions map[string]map[string]protocol.UIActionDecl
formSeq uint64
activeForms map[string]activeForm
}
type activeForm struct {
pluginID string
surfaceID string
sessionID string
generation uint64
owner *extension.RuntimeOwner
emit func(event.Event)
requestFn RequestFunc
warn func(string)
resolve ClientResolver
known map[string]bool
crashed map[string]bool
actions map[string]map[string]protocol.UIActionDecl
instanceID string
submitting bool
}
func formKey(pluginID, surfaceID string) string { return pluginID + "\x00" + surfaceID }
// New builds a Hub bound to one session ID and generation.
func New(opts Options) *Hub {
owner := opts.Owner
@@ -115,16 +128,17 @@ func New(opts Options) *Hub {
owner = extension.RuntimeOwnerOrDefault(nil)
}
return &Hub{
sessionID: strings.TrimSpace(opts.SessionID),
generation: opts.Generation,
owner: owner,
emit: opts.Emit,
requestFn: opts.Request,
warn: opts.Warn,
resolve: opts.Resolve,
known: map[string]bool{},
crashed: map[string]bool{},
actions: map[string]map[string]protocol.UIActionDecl{},
sessionID: strings.TrimSpace(opts.SessionID),
generation: opts.Generation,
owner: owner,
emit: opts.Emit,
requestFn: opts.Request,
warn: opts.Warn,
resolve: opts.Resolve,
known: map[string]bool{},
crashed: map[string]bool{},
actions: map[string]map[string]protocol.UIActionDecl{},
activeForms: map[string]activeForm{},
}
}
@@ -150,6 +164,7 @@ func (h *Hub) BindGeneration(sessionID string, gen uint64) {
defer h.mu.Unlock()
h.sessionID = strings.TrimSpace(sessionID)
h.generation = gen
h.activeForms = map[string]activeForm{}
}
// SetResolver installs the client resolver once the sidecar manager exists.
@@ -179,6 +194,11 @@ func (h *Hub) ClientCrashed(pluginID string) {
if h.known[pluginID] {
h.crashed[pluginID] = true
}
for key, form := range h.activeForms {
if form.pluginID == pluginID {
delete(h.activeForms, key)
}
}
}
// Publish implements the bare UIHandler. Without a per-plugin binding the hub
@@ -213,6 +233,10 @@ func (b binding) Request(ctx context.Context, p protocol.UIRequestParams) (proto
func (h *Hub) gate(pluginID, sessionID string, generation uint64) (stale bool, err error) {
h.mu.Lock()
defer h.mu.Unlock()
return h.gateLocked(pluginID, sessionID, generation)
}
func (h *Hub) gateLocked(pluginID, sessionID string, generation uint64) (stale bool, err error) {
if !h.known[pluginID] {
return false, unknownClientError(pluginID)
}
@@ -253,6 +277,25 @@ func (h *Hub) publish(pluginID string, _ context.Context, p protocol.UIPublishPa
if err != nil {
return protocol.UIPublishResult{}, err
}
h.mu.Lock()
stale, err = h.gateLocked(pluginID, p.SessionID, p.Generation)
if err != nil || stale {
h.mu.Unlock()
if err != nil {
return protocol.UIPublishResult{}, err
}
return protocol.UIPublishResult{Accepted: false}, nil
}
key := formKey(pluginID, p.SurfaceID)
if p.Kind == protocol.UISurfaceForm {
h.formSeq++
payload.FormInstanceID = fmt.Sprintf("form-%d", h.formSeq)
h.activeForms[key] = activeForm{pluginID: pluginID, surfaceID: p.SurfaceID, sessionID: p.SessionID,
generation: p.Generation, instanceID: payload.FormInstanceID}
} else {
delete(h.activeForms, key)
}
h.mu.Unlock()
kind := event.ExtensionSurface
if p.Kind == protocol.UISurfaceStatus {
kind = event.ExtensionStatus
@@ -414,6 +457,54 @@ func (h *Hub) Submit(ctx context.Context, pluginID, surfaceID, sessionID string,
})
}
// SubmitExact pins the form instance and sidecar client while holding the hub
// lock. The sidecar call runs after unlock, so a replacement form cannot be
// mistaken for the request that produced this submission.
func (h *Hub) SubmitExact(ctx context.Context, pluginID, surfaceID, sessionID string, generation uint64, instanceID string, values map[string]any) (protocol.UISubmitResult, error) {
if strings.TrimSpace(surfaceID) == "" || strings.TrimSpace(instanceID) == "" {
return protocol.UISubmitResult{}, &protocol.ProtocolError{Reason: protocol.ErrInvalidParams, Message: "exact form identity is required"}
}
h.mu.Lock()
form, ok := h.activeForms[formKey(pluginID, surfaceID)]
if !ok || form.sessionID != sessionID || form.generation != generation || form.instanceID != instanceID {
h.mu.Unlock()
return protocol.UISubmitResult{}, &protocol.ProtocolError{Reason: protocol.ErrInvalidParams, Message: "extension form is stale"}
}
if form.submitting {
h.mu.Unlock()
return protocol.UISubmitResult{}, &protocol.ProtocolError{Reason: protocol.ErrInvalidParams, Message: "extension form submission is already in progress"}
}
if !h.known[pluginID] {
h.mu.Unlock()
return protocol.UISubmitResult{}, unknownClientError(pluginID)
}
if h.crashed[pluginID] || h.resolve == nil {
h.mu.Unlock()
return protocol.UISubmitResult{}, &protocol.ProtocolError{Reason: protocol.ErrProviderInterrupted, Message: "extension sidecar is unavailable"}
}
client := h.resolve(pluginID)
if client == nil {
h.mu.Unlock()
return protocol.UISubmitResult{}, &protocol.ProtocolError{Reason: protocol.ErrProviderInterrupted, Message: "extension " + pluginID + " has no live sidecar"}
}
form.submitting = true
h.activeForms[formKey(pluginID, surfaceID)] = form
h.mu.Unlock()
result, err := client.UISubmit(ctx, protocol.UISubmitParams{SurfaceID: surfaceID, SessionID: sessionID, Generation: generation, Values: values})
h.mu.Lock()
current, currentOK := h.activeForms[formKey(pluginID, surfaceID)]
if currentOK && current.instanceID == instanceID {
if err != nil || result.Accepted {
delete(h.activeForms, formKey(pluginID, surfaceID))
} else {
current.submitting = false
h.activeForms[formKey(pluginID, surfaceID)] = current
}
}
h.mu.Unlock()
return result, err
}
// actionTarget resolves the live client for one plugin after enforcing the
// binding and session rules shared by InvokeAction and Submit.
func (h *Hub) actionTarget(pluginID, sessionID string) (ActionClient, uint64, error) {
+100 -9
View File
@@ -560,13 +560,15 @@ func TestRebindDropsOldGeneration(t *testing.T) {
// fakeActionClient records UIAction/UISubmit calls for the action tests.
type fakeActionClient struct {
mu sync.Mutex
actionParams []protocol.UIActionParams
actionResult protocol.UIActionResult
actionErr error
submitParams []protocol.UISubmitParams
submitResult protocol.UISubmitResult
submitErr error
mu sync.Mutex
actionParams []protocol.UIActionParams
actionResult protocol.UIActionResult
actionErr error
submitParams []protocol.UISubmitParams
submitResult protocol.UISubmitResult
submitErr error
submitStarted chan struct{}
submitRelease <-chan struct{}
}
func (f *fakeActionClient) UIAction(_ context.Context, p protocol.UIActionParams) (protocol.UIActionResult, error) {
@@ -578,9 +580,98 @@ func (f *fakeActionClient) UIAction(_ context.Context, p protocol.UIActionParams
func (f *fakeActionClient) UISubmit(_ context.Context, p protocol.UISubmitParams) (protocol.UISubmitResult, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.submitParams = append(f.submitParams, p)
return f.submitResult, f.submitErr
started, release := f.submitStarted, f.submitRelease
result, err := f.submitResult, f.submitErr
f.mu.Unlock()
if started != nil {
select {
case started <- struct{}{}:
default:
}
}
if release != nil {
<-release
}
return result, err
}
func publishTestForm(t *testing.T, h *Hub, handler UIHandler, surfaceID string) string {
t.Helper()
result, err := handler.Publish(context.Background(), protocol.UIPublishParams{
SurfaceID: surfaceID, SessionID: "sess-1", Generation: 7, Kind: protocol.UISurfaceForm,
Payload: mustRaw(t, protocol.UIFormPayload{Title: "Form", Fields: []protocol.UIFormField{}}),
})
if err != nil || !result.Accepted {
t.Fatalf("publish form = %+v, %v", result, err)
}
h.mu.Lock()
defer h.mu.Unlock()
return h.activeForms[formKey("alpha", surfaceID)].instanceID
}
func TestSubmitExactPinsInstanceAndRejectsDuplicate(t *testing.T) {
started := make(chan struct{}, 1)
release := make(chan struct{})
fake := &fakeActionClient{submitResult: protocol.UISubmitResult{Accepted: true}, submitStarted: started, submitRelease: release}
h := New(Options{SessionID: "sess-1", Generation: 7, Resolve: func(string) ActionClient { return fake }})
handler := h.HandlerFor("alpha")
first := publishTestForm(t, h, handler, "f1")
if second := publishTestForm(t, h, handler, "f1"); second == first {
t.Fatal("republishing the same surface reused its form instance")
} else {
if _, err := h.SubmitExact(context.Background(), "alpha", "f1", "sess-1", 7, first, nil); err == nil {
t.Fatal("stale form instance reached the sidecar")
}
result := make(chan error, 1)
go func() {
_, err := h.SubmitExact(context.Background(), "alpha", "f1", "sess-1", 7, second, map[string]any{"v": 1})
result <- err
}()
<-started
if _, err := h.SubmitExact(context.Background(), "alpha", "f1", "sess-1", 7, second, nil); err == nil {
t.Fatal("duplicate exact submission was accepted")
}
fake.mu.Lock()
calls := len(fake.submitParams)
fake.mu.Unlock()
if calls != 1 {
t.Fatalf("sidecar submit calls = %d, want 1", calls)
}
close(release)
if err := <-result; err != nil {
t.Fatalf("exact submit: %v", err)
}
}
}
func TestOldSubmitCompletionDoesNotDeleteReplacement(t *testing.T) {
started := make(chan struct{}, 1)
release := make(chan struct{})
fake := &fakeActionClient{submitResult: protocol.UISubmitResult{Accepted: true}, submitStarted: started, submitRelease: release}
h := New(Options{SessionID: "sess-1", Generation: 7, Resolve: func(string) ActionClient { return fake }})
handler := h.HandlerFor("alpha")
oldInstance := publishTestForm(t, h, handler, "f1")
done := make(chan error, 1)
go func() {
_, err := h.SubmitExact(context.Background(), "alpha", "f1", "sess-1", 7, oldInstance, nil)
done <- err
}()
<-started
newInstance := publishTestForm(t, h, handler, "f1")
close(release)
if err := <-done; err != nil {
t.Fatalf("old submit: %v", err)
}
h.mu.Lock()
current, ok := h.activeForms[formKey("alpha", "f1")]
h.mu.Unlock()
if !ok || current.instanceID != newInstance {
t.Fatalf("replacement form was removed by old completion: %+v, present=%v", current, ok)
}
if _, err := h.SubmitExact(context.Background(), "alpha", "f1", "sess-1", 7, newInstance, nil); err != nil {
t.Fatalf("replacement form is not actionable: %v", err)
}
}
func TestRegisterActionsRejectsInvalidIDs(t *testing.T) {
+24 -14
View File
@@ -19,20 +19,22 @@ import (
// Capability tokens advertised on the /auth/token handshake reply so a
// desktop can tell what this serve supports without a second round trip.
const (
capabilitiesHeader = "X-Reasonix-Serve-Capabilities"
capabilityBrowser = "browser"
capabilityPermissionPresets = "permission-presets-v1"
capabilityPresentFiles = "present-files-v1"
capabilityExecutionV2 = "execution-v2"
capabilitySessionHistory = "session-history-v1"
capabilitySessionIdentityV1 = "session-identity-v1"
capabilitySessionOwnershipV1 = "session-ownership-v1"
capabilitySessionContentV1 = "session-content-v1"
capabilitySessionReadV2 = "session-read-v2"
capabilityHistoryWindowV1 = "history-window-v1"
capabilityGoalLifecycleV2 = servecontract.GoalLifecycleV2
capabilityTranscriptOutline = servecontract.TranscriptOutlineV1
capabilityForkTargetsV1 = servecontract.SessionForkTargetsV1
capabilitiesHeader = "X-Reasonix-Serve-Capabilities"
capabilityBrowser = "browser"
capabilityPermissionPresets = "permission-presets-v1"
capabilityPresentFiles = "present-files-v1"
capabilityExecutionV2 = "execution-v2"
capabilitySessionHistory = "session-history-v1"
capabilitySessionIdentityV1 = "session-identity-v1"
capabilitySessionOwnershipV1 = "session-ownership-v1"
capabilitySessionContentV1 = "session-content-v1"
capabilitySessionReadV2 = "session-read-v2"
capabilityHistoryWindowV1 = "history-window-v1"
capabilityExtensionFormInstanceV1 = "extension-form-instance-v1"
capabilityInteractionTargetV1 = "interaction-target-v1"
capabilityGoalLifecycleV2 = servecontract.GoalLifecycleV2
capabilityTranscriptOutline = servecontract.TranscriptOutlineV1
capabilityForkTargetsV1 = servecontract.SessionForkTargetsV1
)
// BrowserBroker is Serve's end of the desktop browser broker: one HTTP
@@ -245,6 +247,14 @@ func (s *Server) capabilities() []string {
}
caps = append(caps, capabilitySessionIdentityV1, capabilitySessionOwnershipV1, capabilitySessionContentV1, capabilitySessionReadV2, capabilityHistoryWindowV1, capabilityGoalLifecycleV2, capabilityForkTargetsV1, servecontract.TranscriptV2, servecontract.SessionExportV1)
}
if _, ok := s.ctl().(interface {
SubmitExtensionFormExact(context.Context, string, string, uint64, string, map[string]any) error
}); ok {
caps = append(caps, capabilityExtensionFormInstanceV1)
}
if _, ok := s.ctl().(exactPromptResolver); ok {
caps = append(caps, capabilityInteractionTargetV1)
}
if s.buildOptions.BrowserExecutor != nil {
caps = append(caps, capabilityBrowser)
}
+4 -4
View File
@@ -146,7 +146,7 @@ func newBrokerTestServer(t *testing.T, opts boot.Options) *Server {
}
func TestServerCapabilitiesFollowBroker(t *testing.T) {
if caps := newBrokerTestServer(t, boot.Options{}).capabilities(); !slices.Equal(caps, []string{capabilityPermissionPresets, capabilityPresentFiles, capabilityExecutionV2, capabilitySessionHistory, capabilityTranscriptOutline}) {
if caps := newBrokerTestServer(t, boot.Options{}).capabilities(); !slices.Equal(caps, []string{capabilityPermissionPresets, capabilityPresentFiles, capabilityExecutionV2, capabilitySessionHistory, capabilityExtensionFormInstanceV1, capabilityInteractionTargetV1, capabilityTranscriptOutline}) {
t.Fatalf("capabilities without broker = %v", caps)
}
broker, err := NewBrowserBroker("http://127.0.0.1:9999", "tok")
@@ -155,7 +155,7 @@ func TestServerCapabilitiesFollowBroker(t *testing.T) {
}
srv := newBrokerTestServer(t, boot.Options{BrowserExecutor: broker})
caps := srv.capabilities()
if !slices.Equal(caps, []string{capabilityPermissionPresets, capabilityPresentFiles, capabilityExecutionV2, capabilitySessionHistory, capabilityBrowser, capabilityTranscriptOutline}) {
if !slices.Equal(caps, []string{capabilityPermissionPresets, capabilityPresentFiles, capabilityExecutionV2, capabilitySessionHistory, capabilityExtensionFormInstanceV1, capabilityInteractionTargetV1, capabilityBrowser, capabilityTranscriptOutline}) {
t.Fatalf("capabilities with broker = %v", caps)
}
}
@@ -236,10 +236,10 @@ func TestHandshakeAdvertisesBrowserCapability(t *testing.T) {
t.Fatalf("handshake status = %d, want 204", resp.StatusCode)
}
got := resp.Header.Get(capabilitiesHeader)
if withBroker && got != capabilityPermissionPresets+","+capabilityPresentFiles+","+capabilityExecutionV2+","+capabilitySessionHistory+","+capabilityBrowser+","+capabilityTranscriptOutline {
if withBroker && got != capabilityPermissionPresets+","+capabilityPresentFiles+","+capabilityExecutionV2+","+capabilitySessionHistory+","+capabilityExtensionFormInstanceV1+","+capabilityInteractionTargetV1+","+capabilityBrowser+","+capabilityTranscriptOutline {
t.Fatalf("capabilities header = %q, want permission, present-files, browser and outline capabilities", got)
}
if !withBroker && got != capabilityPermissionPresets+","+capabilityPresentFiles+","+capabilityExecutionV2+","+capabilitySessionHistory+","+capabilityTranscriptOutline {
if !withBroker && got != capabilityPermissionPresets+","+capabilityPresentFiles+","+capabilityExecutionV2+","+capabilitySessionHistory+","+capabilityExtensionFormInstanceV1+","+capabilityInteractionTargetV1+","+capabilityTranscriptOutline {
t.Fatalf("capabilities header = %q, want permission, present-files and outline capabilities", got)
}
}
+67
View File
@@ -0,0 +1,67 @@
package serve
import (
"encoding/json"
"net/http"
"reasonix/internal/control"
"reasonix/internal/event"
)
type exactPromptResolver interface {
ResolvePromptExact(control.PromptIdentity, control.PromptAnswer) error
}
func (s *Server) resolvePromptExact(w http.ResponseWriter, r *http.Request) {
var body struct {
SessionID string `json:"sessionId"`
PromptID string `json:"promptId"`
TurnID string `json:"turnId"`
RuntimeEpoch string `json:"runtimeEpoch"`
Kind string `json:"kind"`
Answer struct {
Questions []struct {
QuestionID string `json:"questionId"`
Selected []string `json:"selected"`
} `json:"questions"`
Allow bool `json:"allow"`
Session bool `json:"session"`
Persist bool `json:"persist"`
Action string `json:"action"`
Feedback string `json:"feedback"`
Content map[string]any `json:"content"`
Generation uint64 `json:"generation"`
PermissionRevision uint64 `json:"permissionRevision"`
} `json:"answer"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.PromptID == "" || body.TurnID == "" || body.Kind == "" {
http.Error(w, "missing exact prompt identity", http.StatusBadRequest)
return
}
resolver, ok := s.ctl().(exactPromptResolver)
if !ok {
http.Error(w, "exact prompt resolution is unavailable", http.StatusConflict)
return
}
if reader, ok := s.ctl().(interface {
RuntimeStateSnapshot() event.RuntimeStateSnapshot
}); ok {
if current := reader.RuntimeStateSnapshot().SessionID; current != "" && body.SessionID != current {
http.Error(w, "prompt session binding is stale", http.StatusConflict)
return
}
}
questions := make([]event.AskAnswer, len(body.Answer.Questions))
for i, question := range body.Answer.Questions {
questions[i] = event.AskAnswer{QuestionID: question.QuestionID, Selected: question.Selected}
}
answer := control.PromptAnswer{Questions: questions, Allow: body.Answer.Allow, Session: body.Answer.Session,
Persist: body.Answer.Persist, Action: body.Answer.Action, Feedback: body.Answer.Feedback,
Content: body.Answer.Content, Generation: body.Answer.Generation, PermissionRevision: body.Answer.PermissionRevision}
identity := control.PromptIdentity{PromptID: body.PromptID, TurnID: body.TurnID, RuntimeEpoch: body.RuntimeEpoch, Kind: control.PromptKind(body.Kind)}
if err := resolver.ResolvePromptExact(identity, answer); err != nil {
http.Error(w, err.Error(), http.StatusConflict)
return
}
w.WriteHeader(http.StatusNoContent)
}
+50
View File
@@ -0,0 +1,50 @@
package serve
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"reasonix/internal/config"
"reasonix/internal/control"
"reasonix/internal/event"
)
type exactPromptController struct {
control.SessionAPI
sessionID string
calls []control.PromptIdentity
}
func (c *exactPromptController) RuntimeStateSnapshot() event.RuntimeStateSnapshot {
return event.RuntimeStateSnapshot{SchemaVersion: 1, SessionID: c.sessionID}
}
func (c *exactPromptController) ResolvePromptExact(identity control.PromptIdentity, _ control.PromptAnswer) error {
c.calls = append(c.calls, identity)
return nil
}
func TestServeExactPromptChecksSessionBeforeController(t *testing.T) {
bc := NewBroadcaster()
ctrl := &exactPromptController{SessionAPI: control.New(control.Options{Sink: bc}), sessionID: "session-a"}
srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
defer srv.Close()
post := func(sessionID string) int {
resp, err := http.Post(srv.URL+"/resolve-prompt", "application/json", strings.NewReader(
`{"sessionId":"`+sessionID+`","promptId":"p1","turnId":"t1","runtimeEpoch":"r1","kind":"ask","answer":{"questions":[]}}`,
))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
return resp.StatusCode
}
if got := post("session-old"); got != http.StatusConflict || len(ctrl.calls) != 0 {
t.Fatalf("stale exact prompt status/calls = %d/%d", got, len(ctrl.calls))
}
if got := post("session-a"); got != http.StatusNoContent || len(ctrl.calls) != 1 || ctrl.calls[0].RuntimeEpoch != "r1" {
t.Fatalf("current exact prompt status/calls = %d/%+v", got, ctrl.calls)
}
}
+32 -3
View File
@@ -5,22 +5,51 @@ import (
"encoding/json"
"net/http"
"strings"
"reasonix/internal/event"
)
type extensionFormSubmitter interface {
SubmitExtensionForm(ctx context.Context, pluginID, surfaceID string, values map[string]any) error
}
type exactExtensionFormSubmitter interface {
SubmitExtensionFormExact(ctx context.Context, pluginID, surfaceID string, generation uint64, formInstanceID string, values map[string]any) error
}
func (s *Server) submitExtensionForm(w http.ResponseWriter, r *http.Request) {
var body struct {
PluginID string `json:"pluginId"`
SurfaceID string `json:"surfaceId"`
Values map[string]any `json:"values"`
SessionID string `json:"sessionId"`
PluginID string `json:"pluginId"`
SurfaceID string `json:"surfaceId"`
Generation uint64 `json:"generation"`
FormInstanceID string `json:"formInstanceId"`
Values map[string]any `json:"values"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || strings.TrimSpace(body.PluginID) == "" || strings.TrimSpace(body.SurfaceID) == "" {
http.Error(w, "missing extension form identity", http.StatusBadRequest)
return
}
if body.FormInstanceID != "" {
submitter, ok := s.ctl().(exactExtensionFormSubmitter)
if !ok {
http.Error(w, "exact extension form submission is unavailable", http.StatusConflict)
return
}
if reader, ok := s.ctl().(interface {
RuntimeStateSnapshot() event.RuntimeStateSnapshot
}); ok {
if current := reader.RuntimeStateSnapshot().SessionID; current != "" && body.SessionID != current {
http.Error(w, "extension form session binding is stale", http.StatusConflict)
return
}
}
if err := submitter.SubmitExtensionFormExact(r.Context(), body.PluginID, body.SurfaceID, body.Generation, body.FormInstanceID, body.Values); err != nil {
http.Error(w, err.Error(), http.StatusConflict)
return
}
w.WriteHeader(http.StatusNoContent)
return
}
submitter, ok := s.ctl().(extensionFormSubmitter)
if !ok {
http.Error(w, "extension form submission is unavailable", http.StatusConflict)
@@ -9,12 +9,27 @@ import (
"reasonix/internal/config"
"reasonix/internal/control"
"reasonix/internal/event"
)
type extensionFormController struct {
control.SessionAPI
pluginID, surfaceID string
values map[string]any
generation uint64
instanceID string
exactCalls int
sessionID string
}
func (c *extensionFormController) RuntimeStateSnapshot() event.RuntimeStateSnapshot {
return event.RuntimeStateSnapshot{SchemaVersion: 1, SessionID: c.sessionID}
}
func (c *extensionFormController) SubmitExtensionFormExact(_ context.Context, pluginID, surfaceID string, generation uint64, instanceID string, values map[string]any) error {
c.exactCalls++
c.pluginID, c.surfaceID, c.generation, c.instanceID, c.values = pluginID, surfaceID, generation, instanceID, values
return nil
}
func (c *extensionFormController) SubmitExtensionForm(_ context.Context, pluginID, surfaceID string, values map[string]any) error {
@@ -22,6 +37,29 @@ func (c *extensionFormController) SubmitExtensionForm(_ context.Context, pluginI
return nil
}
func TestServeExactExtensionFormChecksSessionBeforeController(t *testing.T) {
bc := NewBroadcaster()
ctrl := &extensionFormController{SessionAPI: control.New(control.Options{Sink: bc}), sessionID: "session-a"}
srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
defer srv.Close()
post := func(sessionID string) int {
resp, err := http.Post(srv.URL+"/extension-form", "application/json", strings.NewReader(
`{"sessionId":"`+sessionID+`","pluginId":"remote-plugin","surfaceId":"setup","generation":7,"formInstanceId":"form-2","values":{"region":"sg"}}`,
))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
return resp.StatusCode
}
if got := post("session-old"); got != http.StatusConflict || ctrl.exactCalls != 0 {
t.Fatalf("stale exact form status/calls = %d/%d", got, ctrl.exactCalls)
}
if got := post("session-a"); got != http.StatusNoContent || ctrl.exactCalls != 1 || ctrl.generation != 7 || ctrl.instanceID != "form-2" {
t.Fatalf("current exact form status/call = %d/%+v", got, ctrl)
}
}
func TestServeExtensionFormSubmissionRoutesToController(t *testing.T) {
bc := NewBroadcaster()
ctrl := &extensionFormController{SessionAPI: control.New(control.Options{Sink: bc})}
+1
View File
@@ -542,6 +542,7 @@ func (s *Server) handler() http.Handler {
mux.HandleFunc("POST /jobs/cancel", s.foregroundMutation(s.jobsCancel))
mux.HandleFunc("POST /answer", s.foregroundMutation(s.answer))
mux.HandleFunc("POST /mcp-interaction", s.foregroundMutation(s.mcpInteraction))
mux.HandleFunc("POST /resolve-prompt", s.foregroundMutation(s.resolvePromptExact))
mux.HandleFunc("POST /resume", s.resume)
mux.HandleFunc("POST /forget", s.foregroundMutation(s.forget))
mux.HandleFunc("GET /checkpoints", s.checkpoints)