From fdee20d7d05848a4bba7bec9a903d7282cbf02e9 Mon Sep 17 00:00:00 2001 From: Yuqing Yang Date: Mon, 5 Oct 2026 02:03:04 +0000 Subject: [PATCH] feat: expose Agent Node cwd override Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../WorkingDirectoryOverride.test.tsx | 135 ++++++++++++++++++ .../ChatPanel/WorkingDirectoryOverride.tsx | 105 ++++++++++++++ .../src/components/Panels/ChatPanel/index.tsx | 69 +++++++-- apps/web/src/i18n/resources/en/common.json | 5 + apps/web/src/i18n/resources/zh-CN/common.json | 5 + apps/web/src/store/conversationOwner.test.ts | 56 ++++++++ apps/web/src/store/conversationOwner.ts | 33 ++++- docs/architecture/agent-profiles.md | 2 +- docs/architecture/question-node.md | 35 ++--- 9 files changed, 415 insertions(+), 30 deletions(-) create mode 100644 apps/web/src/components/Panels/ChatPanel/WorkingDirectoryOverride.test.tsx create mode 100644 apps/web/src/components/Panels/ChatPanel/WorkingDirectoryOverride.tsx diff --git a/apps/web/src/components/Panels/ChatPanel/WorkingDirectoryOverride.test.tsx b/apps/web/src/components/Panels/ChatPanel/WorkingDirectoryOverride.test.tsx new file mode 100644 index 000000000..73ec530b3 --- /dev/null +++ b/apps/web/src/components/Panels/ChatPanel/WorkingDirectoryOverride.test.tsx @@ -0,0 +1,135 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { i18n } from '@/i18n'; + +import { WorkingDirectoryOverride } from './WorkingDirectoryOverride'; + +let container: HTMLDivElement; +let root: Root; + +async function render( + props: React.ComponentProps, +) { + await act(async () => root.render()); +} + +beforeEach(async () => { + await i18n.changeLanguage('en'); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); +}); + +describe('WorkingDirectoryOverride', () => { + it('shows Profile inheritance and updates it without persisting a copy', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + await render({ + profileWorkingDirPath: '/profiles/first', + editable: true, + saving: false, + onSave, + }); + + const input = container.querySelector('input')!; + expect(input.placeholder).toBe('/profiles/first'); + expect(container.textContent).toContain( + 'Inheriting Profile directory: /profiles/first', + ); + + await render({ + profileWorkingDirPath: '/profiles/second', + editable: true, + saving: false, + onSave, + }); + expect(container.querySelector('input')?.value).toBe(''); + expect(container.querySelector('input')?.placeholder).toBe( + '/profiles/second', + ); + expect(onSave).not.toHaveBeenCalled(); + }); + + it('preserves an explicit override across Profile changes and clears to null', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + await render({ + profileWorkingDirPath: '/profiles/first', + workingDirPath: '/node/work', + editable: true, + saving: false, + onSave, + }); + await render({ + profileWorkingDirPath: '/profiles/second', + workingDirPath: '/node/work', + editable: true, + saving: false, + onSave, + }); + const input = container.querySelector('input')!; + expect(input.value).toBe('/node/work'); + + await act(async () => { + Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + 'value', + )!.set!.call(input, ''); + input.dispatchEvent(new Event('input', { bubbles: true })); + input.dispatchEvent(new FocusEvent('focusout', { bubbles: true })); + }); + expect(onSave).toHaveBeenCalledWith(null); + }); + + it('keeps a locked explicit override visible but hides locked inheritance', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + await render({ + profileWorkingDirPath: '/profiles/default', + workingDirPath: '/node/work', + editable: false, + saving: false, + onSave, + }); + expect(container.textContent).toContain('/node/work'); + expect(container.querySelector('input')).toBeNull(); + + await render({ + profileWorkingDirPath: '/profiles/default', + editable: false, + saving: false, + onSave, + }); + expect(container.textContent).toBe(''); + }); + + it('surfaces server validation errors without replacing the draft', async () => { + const onSave = vi.fn().mockRejectedValue(new Error('Must be absolute')); + await render({ + profileWorkingDirPath: '/profiles/default', + editable: true, + saving: false, + onSave, + }); + const input = container.querySelector('input')!; + await act(async () => { + Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + 'value', + )!.set!.call(input, 'relative/path'); + input.dispatchEvent(new Event('input', { bubbles: true })); + input.dispatchEvent(new FocusEvent('focusout', { bubbles: true })); + }); + expect(container.querySelector('[role="alert"]')?.textContent).toBe( + 'Must be absolute', + ); + expect(input.value).toBe('relative/path'); + }); +}); diff --git a/apps/web/src/components/Panels/ChatPanel/WorkingDirectoryOverride.tsx b/apps/web/src/components/Panels/ChatPanel/WorkingDirectoryOverride.tsx new file mode 100644 index 000000000..32c25bdd8 --- /dev/null +++ b/apps/web/src/components/Panels/ChatPanel/WorkingDirectoryOverride.tsx @@ -0,0 +1,105 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { TextInput } from '@/components/Common/TextInput'; + +interface WorkingDirectoryOverrideProps { + profileWorkingDirPath: string; + workingDirPath?: string; + editable: boolean; + saving: boolean; + onSave: (workingDirPath: string | null) => Promise; +} + +export function WorkingDirectoryOverride({ + profileWorkingDirPath, + workingDirPath, + editable, + saving, + onSave, +}: WorkingDirectoryOverrideProps) { + const { t } = useTranslation(); + const [draft, setDraft] = useState(workingDirPath ?? ''); + const [error, setError] = useState(null); + + useEffect(() => { + setDraft(workingDirPath ?? ''); + setError(null); + }, [workingDirPath]); + + if (!editable && !workingDirPath) return null; + + const commit = async () => { + const trimmed = draft.trim(); + if (trimmed === (workingDirPath ?? '')) return; + setError(null); + try { + await onSave(trimmed || null); + } catch (saveError) { + setError( + saveError instanceof Error + ? saveError.message + : t('chat.workingDirectoryOverrideSaveFailed'), + ); + } + }; + + if (!editable) { + return ( +
+
+ {t('chat.workingDirectoryOverride')} +
+
+ {workingDirPath} +
+
+ {t('chat.workingDirectoryNodeOnlyLocked')} +
+
+ ); + } + + return ( +
+ + setDraft(event.target.value)} + onBlur={() => void commit()} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + event.currentTarget.blur(); + } else if (event.key === 'Escape') { + setDraft(workingDirPath ?? ''); + setError(null); + event.currentTarget.blur(); + } + }} + placeholder={profileWorkingDirPath} + aria-label={t('chat.workingDirectoryOverride')} + mono + disabled={saving} + className="w-full" + /> +
+ {draft.trim() + ? t('chat.workingDirectoryNodeOnly') + : t('chat.workingDirectoryInherited', { + path: profileWorkingDirPath, + })} +
+ {error && ( +
+ {error} +
+ )} +
+ ); +} diff --git a/apps/web/src/components/Panels/ChatPanel/index.tsx b/apps/web/src/components/Panels/ChatPanel/index.tsx index b75e4b7f7..ea01cfa19 100644 --- a/apps/web/src/components/Panels/ChatPanel/index.tsx +++ b/apps/web/src/components/Panels/ChatPanel/index.tsx @@ -50,6 +50,7 @@ import { acknowledgeConversationResult, awaitConversationDraft, saveConversationDraft, + saveConversationWorkingDirectoryOverride, resolveConversationAgentBinding, resolveConversationOwnerSource, } from '@/store/conversationOwner'; @@ -78,6 +79,7 @@ import { ChangeReviewCard } from './ChangeReviewCard'; import { parseSlashInvocations } from './parseSlashInvocations'; import { saveChatAsQuestion } from './saveChatAsQuestion'; import { ThreadChatInput } from './ThreadChatInput'; +import { WorkingDirectoryOverride } from './WorkingDirectoryOverride'; import { useAgentStream } from '../../../hooks/useAgentStream'; import { useChatHistory } from '../../../hooks/useChatHistory'; import { MessageList } from '../../Messages/MessageList'; @@ -169,6 +171,7 @@ export const ChatPanel = ({ conversationOwnerSource?.agentBindingPolicy === 'fixed' || conversationOwnerSource?.bindingState === 'bound'; const [savingAgentDraft, setSavingAgentDraft] = useState(false); + const [savingWorkingDirectory, setSavingWorkingDirectory] = useState(false); const activelyViewingOwner = useActivelyViewingQuestionNode( activeConversationView?.presentationAnchor.nodeId ?? '', ); @@ -811,13 +814,40 @@ export const ChatPanel = ({ // read-only. Picking an agent rebinds the *current* (empty) thread in // place; it never mints a new thread. const threadHasUserMessage = messages.some((m) => m.role === 'user'); - const agentSelectorEditable = + const preparationEditable = !viewingQuestionBindingIsFixed && (activeConversationView ? conversationOwnerSource?.bindingState !== 'bound' : !threadHasUserMessage) && !savingAgentDraft && !isLoading; + const agentSelectorEditable = preparationEditable && !savingWorkingDirectory; + const selectedExternalProfile = + agentBinding.kind === 'external' + ? acpProfiles.find((profile) => profile.id === agentBinding.profileId) + : undefined; + const workingDirectoryOverride = + conversationOwnerSource?.agentLaunchOverrides?.workingDirPath; + const showWorkingDirectoryOverride = + !!activeConversationView && + ((!!selectedExternalProfile && preparationEditable) || + !!workingDirectoryOverride); + const handleSaveWorkingDirectory = useCallback( + async (workingDirPath: string | null) => { + if (!activeConversationView) return; + setSavingWorkingDirectory(true); + try { + await saveConversationWorkingDirectoryOverride( + activeConversationView, + workingDirPath, + ); + onCommit?.(); + } finally { + setSavingWorkingDirectory(false); + } + }, + [activeConversationView, onCommit], + ); const handleSelectAgent = useCallback( async (choice: AgentChoice) => { // Agent binding is immutable once a turn starts (1 thread = 1 binding). @@ -1060,16 +1090,33 @@ export const ChatPanel = ({ slashLoading={slashLoading} onSlashMenuIntent={refreshSlashCommands} agentSelectorSlot={ - +
+
+ +
+ {showWorkingDirectoryOverride && ( + + )} +
} acpSelectorsSlot={ agentBinding.kind === 'external' ? ( diff --git a/apps/web/src/i18n/resources/en/common.json b/apps/web/src/i18n/resources/en/common.json index 074a8ffad..49b2b27ff 100644 --- a/apps/web/src/i18n/resources/en/common.json +++ b/apps/web/src/i18n/resources/en/common.json @@ -765,6 +765,11 @@ } }, "chat": { + "workingDirectoryOverride": "Working directory override", + "workingDirectoryInherited": "Inheriting Profile directory: {{path}}", + "workingDirectoryNodeOnly": "Applies only to this Agent Node.", + "workingDirectoryNodeOnlyLocked": "Node-only override captured for this execution.", + "workingDirectoryOverrideSaveFailed": "Failed to save working directory override", "showEarlierTurns": "Show {{count}} earlier turns", "loadingEarlierTurns": "Loading earlier turns…", "backToBottom": "Back to bottom", diff --git a/apps/web/src/i18n/resources/zh-CN/common.json b/apps/web/src/i18n/resources/zh-CN/common.json index be029bcc0..d6f4e0aaa 100644 --- a/apps/web/src/i18n/resources/zh-CN/common.json +++ b/apps/web/src/i18n/resources/zh-CN/common.json @@ -765,6 +765,11 @@ } }, "chat": { + "workingDirectoryOverride": "工作目录覆盖", + "workingDirectoryInherited": "继承 Profile 目录:{{path}}", + "workingDirectoryNodeOnly": "仅应用于此 Agent 节点。", + "workingDirectoryNodeOnlyLocked": "此执行已固定使用该节点专属覆盖。", + "workingDirectoryOverrideSaveFailed": "保存工作目录覆盖失败", "showEarlierTurns": "显示更早的 {{count}} 个轮次", "loadingEarlierTurns": "正在加载更早的轮次…", "backToBottom": "回到底部", diff --git a/apps/web/src/store/conversationOwner.test.ts b/apps/web/src/store/conversationOwner.test.ts index bb0b0a76f..53a899fed 100644 --- a/apps/web/src/store/conversationOwner.test.ts +++ b/apps/web/src/store/conversationOwner.test.ts @@ -27,6 +27,7 @@ import { shouldComposeConversationOwner, validateConversationView, saveConversationDraft, + saveConversationWorkingDirectoryOverride, awaitConversationDraft, acknowledgeConversationResult, } from './conversationOwner'; @@ -324,6 +325,61 @@ describe('conversation owner routing', () => { ).toEqual({ kind: 'internal' }); }); + it('persists only an explicit Node cwd and clears it back to inheritance', async () => { + useCanvasStore.getState()._setStateNoAutosave({ + nodes: [ + { + id: 'node-source', + type: 'question', + position: { x: 0, y: 0 }, + data: { + type: 'question', + threadId: 'thread-source', + content: '', + agentLaunchOverrides: { + additionalInitialPreamble: 'Keep this instruction.', + }, + }, + }, + ], + }); + + await saveConversationWorkingDirectoryOverride( + ownerView, + ' /work/project ', + ); + expect( + postCanvasExecute.mock.calls[0][1].commands[0].patches[0].patch, + ).toEqual({ + agentLaunchOverrides: { + additionalInitialPreamble: 'Keep this instruction.', + workingDirPath: '/work/project', + }, + }); + + useCanvasStore.getState()._setStateNoAutosave({ + nodes: [ + { + id: 'node-source', + type: 'question', + position: { x: 0, y: 0 }, + data: { + type: 'question', + threadId: 'thread-source', + content: '', + agentLaunchOverrides: { + workingDirPath: '/work/project', + }, + }, + }, + ], + }); + await saveConversationWorkingDirectoryOverride(ownerView, null); + expect( + postCanvasExecute.mock.calls[1][1].commands[0].patches[0].patch, + ).toEqual({ agentLaunchOverrides: null }); + }); + it('omits server-owned fields regardless of binding policy', () => { expect( filterClientOwnedQuestionPatch( diff --git a/apps/web/src/store/conversationOwner.ts b/apps/web/src/store/conversationOwner.ts index 79606ac71..d8f88cc93 100644 --- a/apps/web/src/store/conversationOwner.ts +++ b/apps/web/src/store/conversationOwner.ts @@ -8,7 +8,11 @@ import { acknowledgeAgentNodeResult, postCanvasExecute } from '@/api/canvas'; import { rememberConversationAgentBinding } from '@/store/acpProfilesStore'; import useCanvasStore, { awaitQuestionCreation } from '@/store/canvasStore'; -import type { AgentBinding, AgentConversationView } from '@huabu/shared'; +import type { + AgentBinding, + AgentConversationView, + AgentLaunchOverrides, +} from '@huabu/shared'; import type { Delta } from '@huabu/shared/canvas-engine'; import type { Node } from '@xyflow/react'; @@ -22,6 +26,7 @@ export type ConversationOwnerSource = { agentBinding?: AgentBinding; agentBindingPolicy?: 'selectable' | 'fixed'; bindingState?: 'editing' | 'bound'; + agentLaunchOverrides?: AgentLaunchOverrides; invocationToken?: string; pendingInkIntentLabel?: boolean; content?: unknown; @@ -211,6 +216,32 @@ export function saveConversationDraft( return save; } +export async function saveConversationWorkingDirectoryOverride( + view: AgentConversationView, + workingDirPath: string | null, +): Promise { + const state = useCanvasStore.getState(); + const source = resolveConversationOwnerSource( + state.canvasId, + state.nodes, + view, + ); + if (!source) { + throw new ConversationIntegrityError( + 'Conversation owner no longer matches the active Agent node', + ); + } + + const nextOverrides = { ...source.agentLaunchOverrides }; + if (workingDirPath === null) delete nextOverrides.workingDirPath; + else nextOverrides.workingDirPath = workingDirPath.trim(); + + await patchConversationOwnerNode(view, { + agentLaunchOverrides: + Object.keys(nextOverrides).length > 0 ? nextOverrides : null, + }); +} + export async function awaitConversationDraft( view: AgentConversationView, ): Promise { diff --git a/docs/architecture/agent-profiles.md b/docs/architecture/agent-profiles.md index 48f379639..6fa5c14cd 100644 --- a/docs/architecture/agent-profiles.md +++ b/docs/architecture/agent-profiles.md @@ -49,7 +49,7 @@ The same capability-driven form handles creation and editing, including automati After a successful structured ACP bootstrap, the driver persists the resolved `harnessLaunchPlan` in driver state and reuses it during recovery. The Profile recipe remains immutable and independent of later edits or deletion. The plan freezes launch arguments and launch-specific environment, not installed binary versions, inherited runtime environment, or a pre-bootstrap failure. -The editable Profile is a template, not an execution. A conversation freezes its recipe, cwd, instructions, preferences, and `profileExecutionRevision` at first realization (first prompt or first mode/model/config control), not when its tab or Agent Node is created. Unrealized conversations use the latest template; realized executions and resumed/restarted processes keep their persisted snapshot. Node-specific cwd overrides still take precedence. A successful non-no-op patch advances `revision`; launch/cwd changes also advance `executionRevision`. Missing legacy revisions mean zero without rewriting the record. HTTP edits require `expectedRevision`; stale saves fail with `409 profile_conflict`, including changes that race asynchronous daemon validation. +The editable Profile is a template, not an execution. A conversation freezes its recipe, cwd, instructions, preferences, and `profileExecutionRevision` at first realization (first prompt or first mode/model/config control), not when its tab or Agent Node is created. Unrealized conversations use the latest template; realized executions and resumed/restarted processes keep their persisted snapshot. An editable external Agent Node may persist a Node-specific cwd override in `agentLaunchOverrides.workingDirPath`; an absent override inherits the selected Profile without copying its value, while an explicit override survives pre-realization Profile changes and takes precedence only for that Node. A successful non-no-op Profile patch advances `revision`; launch/cwd changes also advance `executionRevision`. Missing legacy revisions mean zero without rewriting the record. HTTP edits require `expectedRevision`; stale saves fail with `409 profile_conflict`, including changes that race asynchronous daemon validation. Runtime-relevant edits invalidate the Profile schema cache. Cache warm-starts and metadata writes are fenced against the frozen execution revision, so an old thread cannot repopulate or consume the new template's cache. Successful model/thought-level choices may update known-harness Profile preferences only from a matching execution revision; Custom keeps such choices session-local. Existing thread metadata remains authoritative for its live controls regardless of Profile edits. diff --git a/docs/architecture/question-node.md b/docs/architecture/question-node.md index e802bf28b..88dcfae85 100644 --- a/docs/architecture/question-node.md +++ b/docs/architecture/question-node.md @@ -40,23 +40,23 @@ User-facing node terminology is **Agent Node** in English and **Agent 节点** i `QuestionNodeData` ([node.ts](../../packages/shared/src/types/canvas/node.ts)): -| Field | Persisted | Notes | -| ------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------ | -| `content` | sidecar | The question text; stored in `nodes/.md` body like text/note (`TEXT_BEARING_NODE_TYPES`), stripped from the structure PUT | -| `status` | ✅ | Optional sparse status: absent means `idle`; non-default values are `running` / `done` / `error` | -| `threadId` | ✅ | Owns one chat thread; minted on first compose | -| `conversationTitleSource` | ✅ | Server-owned naming provenance (`user` / `generated` / `acp` / `fallback` / null); the title value remains the canonical `label` | -| `agentBinding` | ✅ | Acknowledged preparation draft; driver/Profile identity becomes immutable after canonical execution binding | -| `agentBindingPolicy` | ✅ | Optional `selectable` / `fixed`; absent means selectable, while service-created Agent Nodes use fixed before first send | -| `agentIcon` | ✅ | External Agent's bind-time avatar fallback; current Profile icon wins while that Profile still exists | -| `agentLaunchOverrides` | ✅ | Optional bounded cwd and additional-initial-preamble overrides for a service-created external Agent Node | -| `agentMode` | ✅ | `operate` (default) / `ask` for the internal agent | -| `errorMessage` | ✅ | Set on `status === 'error'` | -| `viewed` | ✅ | Drives unread terminal-state attention on the Agent avatar | -| `bindingState` | ✅ | Server-owned `editing` / `bound`; Bound acknowledges a validated canonical Agenetes record and never demotes | -| `invocationToken` | ✅ | Server-owned current or last admitted prompt identity; fences terminal writes and viewed acknowledgements | -| `responseSummary` | reserved | Teaser field; not yet written by the runner | -| `pendingInkIntentLabel` | ✅ | Marks only a newly created Ink Question whose placeholder may be replaced by the first structured inferred intent | +| Field | Persisted | Notes | +| ------------------------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `content` | sidecar | The question text; stored in `nodes/.md` body like text/note (`TEXT_BEARING_NODE_TYPES`), stripped from the structure PUT | +| `status` | ✅ | Optional sparse status: absent means `idle`; non-default values are `running` / `done` / `error` | +| `threadId` | ✅ | Owns one chat thread; minted on first compose | +| `conversationTitleSource` | ✅ | Server-owned naming provenance (`user` / `generated` / `acp` / `fallback` / null); the title value remains the canonical `label` | +| `agentBinding` | ✅ | Acknowledged preparation draft; driver/Profile identity becomes immutable after canonical execution binding | +| `agentBindingPolicy` | ✅ | Optional `selectable` / `fixed`; absent means selectable, while service-created Agent Nodes use fixed before first send | +| `agentIcon` | ✅ | External Agent's bind-time avatar fallback; current Profile icon wins while that Profile still exists | +| `agentLaunchOverrides` | ✅ | Optional bounded cwd and additional-initial-preamble overrides for an external Agent Node; user-created Nodes may edit cwd before binding | +| `agentMode` | ✅ | `operate` (default) / `ask` for the internal agent | +| `errorMessage` | ✅ | Set on `status === 'error'` | +| `viewed` | ✅ | Drives unread terminal-state attention on the Agent avatar | +| `bindingState` | ✅ | Server-owned `editing` / `bound`; Bound acknowledges a validated canonical Agenetes record and never demotes | +| `invocationToken` | ✅ | Server-owned current or last admitted prompt identity; fences terminal writes and viewed acknowledgements | +| `responseSummary` | reserved | Teaser field; not yet written by the runner | +| `pendingInkIntentLabel` | ✅ | Marks only a newly created Ink Question whose placeholder may be replaced by the first structured inferred intent | Not persisted: the server invocation phase and cancellation controller, plus the browser's stream controller and request feedback. The complete Node is a read model, not a writable snapshot: ordinary Canvas PUT, commands, and undo omit or preserve the server-owned fields and cannot replace the thread association. Question nodes are content nodes: preprocessing delegates their `content` to `ConversationTitleService.initializeQuestion()` rather than running a separate `generate_label` stage. The profile has no `persist_source`, so Questions do **not** enter the knowledge base. They remain visible to agents (`type: 'question'` in `get_space_outline`). See [node-preprocessing.md](./node-preprocessing.md) for the profile and option gates. @@ -77,6 +77,7 @@ Created like any node via `CREATE_NODES` ([resolveAddNodes.ts](../../apps/web/sr - **Idle** → double-click opens compose (§5). - An idle node with `agentBindingPolicy: fixed` opens compose with its persisted binding and a read-only Agent selector. Ordinary Editing nodes retain the picker; Bound nodes cannot switch execution identity even when a first control created no messages. +- An Editing Node bound to an external Profile exposes an optional Node-specific working-directory override below the Agent selector. Empty means inherit the selected Profile without persisting a copy; an explicit value survives Profile changes and applies only to that Node. The control has no server-local folder picker because the Profile's Agentlet may run on another machine. Binding locks the override with the rest of execution preparation; a locked explicit value remains visible as a read-only summary. - After sending: **running → done / error**. - `AgentThreadService` owns lifecycle for every node-backed invocation, regardless of policy. Admission publishes a new token and `running` before dispatch, installs cancellation before slow preparation, and keeps the turn lease through settlement. `AgentNodeLifecycle` fills only freshly read empty, never-submitted content and projects the matching terminal result. Existing content, previous submission tokens, and legacy conversation history prevent follow-ups from replacing authored text. - Loading and reconnect observe server state; they never infer success from old history or repair status in the browser. A persisted running node without live tracking after restart does not establish an outcome, introduce a new badge, or trigger replay. Existing retry/admission behavior remains unchanged.