From b4fe6055bb0a001c0104e8df900b612c14190915 Mon Sep 17 00:00:00 2001 From: drown0315 Date: Thu, 9 Jul 2026 11:40:32 +0800 Subject: [PATCH 1/6] chore: mark agent session command issue complete --- issues/0.0.3-selection-chat.md | 35 ++++++++++++++++++---------------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/issues/0.0.3-selection-chat.md b/issues/0.0.3-selection-chat.md index 825d174..b415ba0 100644 --- a/issues/0.0.3-selection-chat.md +++ b/issues/0.0.3-selection-chat.md @@ -363,22 +363,25 @@ The standard CLI command loop should support a combined command shape like `agen ## Acceptance criteria -- [ ] The standard agent loop is poll, process, reply, poll again. -- [ ] A command can write an agent reply and continue polling in one flow. -- [ ] A command can write a system error message. -- [ ] Command-level errors produce `system` messages, not `agent` messages. -- [ ] Normal agent explanations of failed work use `agent` messages. -- [ ] Agent replies are correlated with the user message ID they answer. -- [ ] Agent error messages can be correlated with the user message where applicable. -- [ ] Reply write failure prevents continued polling and returns an error. -- [ ] Error write failure prevents continued polling and returns an error. -- [ ] Agent reply success followed by poll continuation failure keeps the reply and moves Agent Status to `Waiting for agent`. -- [ ] Poller output includes the current message payload. -- [ ] Poller output includes next-step guidance for the agent. -- [ ] The CLI command is the formal Agent Session Command contract. -- [ ] HTTP agent endpoints are treated as the underlying transport for the command. -- [ ] The command terminology is not exposed in the product UI. -- [ ] Manual review confirms the command shape works naturally in the launching agent session. +- [x] The standard agent loop is poll, process, reply, poll again. +- [x] A command can write an agent reply and continue polling in one flow. +- [x] A command can write a system error message. +- [x] Command-level errors produce `system` messages, not `agent` messages. +- [x] Normal agent explanations of failed work use `agent` messages. +- [x] Agent replies are correlated with the user message ID they answer. +- [x] Agent error messages can be correlated with the user message where applicable. +- [x] Reply write failure prevents continued polling and returns an error. +- [x] Error write failure prevents continued polling and returns an error. +- [x] Agent reply success followed by poll continuation failure keeps the reply and moves Agent Status to `Waiting for agent`. +- [x] Poller output includes the current message payload. +- [x] Poller output includes next-step guidance for the agent. +- [x] The CLI command is the formal Agent Session Command contract. +- [x] HTTP agent endpoints are treated as the underlying transport for the command. +- [x] The command terminology is not exposed in the product UI. +- [x] Manual review confirms the command shape works naturally in the launching agent session. + +Completed via `issues/0.0.3-agent-session-command.md`, which split this +contract work into six completed Agent Session Command slices. ## Blocked by From 86a19f6e133d81b7e23e2d1599172197cc3b113e Mon Sep 17 00:00:00 2001 From: drown0315 Date: Thu, 9 Jul 2026 12:31:54 +0800 Subject: [PATCH 2/6] fix: harden selection chat boundaries --- apps/bridge/test/chat/chat_ingress_test.dart | 36 +++++++++ apps/web/src/app/AskUiWorkbench.tsx | 1 + apps/web/src/chat/chatComposerState.test.ts | 64 ++++++++++++++- apps/web/src/chat/chatComposerState.ts | 16 ++++ apps/web/src/chat/chatSessionState.test.ts | 77 +++++++++++++++++++ apps/web/src/components/chat/ChatComposer.tsx | 6 +- apps/web/src/components/chat/ChatPanel.tsx | 3 + .../components/chat/SelectedWidgetSection.tsx | 11 ++- .../components/chat/chatPanelContent.test.ts | 17 ++++ .../chat/useSelectionCommentPanelFlow.ts | 4 + .../live-app-surface/DeviceShell.tsx | 37 +++++++-- .../deviceControlProtocol.test.ts | 25 ++++++ .../live-app-surface/deviceControlProtocol.ts | 10 +++ .../selectionCommentValidation.test.ts | 25 ++++++ .../selectionCommentValidation.ts | 26 +++++++ issues/0.0.3-selection-chat.md | 46 +++++------ 16 files changed, 369 insertions(+), 35 deletions(-) diff --git a/apps/bridge/test/chat/chat_ingress_test.dart b/apps/bridge/test/chat/chat_ingress_test.dart index 7d8c209..b6d8d4e 100644 --- a/apps/bridge/test/chat/chat_ingress_test.dart +++ b/apps/bridge/test/chat/chat_ingress_test.dart @@ -206,6 +206,42 @@ void main() { (unowned as RejectedChatIngressMessage).error, 'invalid_chat_parts'); expect(owned, isA()); }); + + test('does not restore snapshot ownership in a restarted Bridge Session', + () { + const snapshotPath = '/tmp/ask-ui/session-1/snapshots/comment.png'; + existingSnapshotPaths.add(snapshotPath); + session.manageLocalPath('/tmp/ask-ui/session-1'); + final originalSessionResult = ingress.parseMessage( + { + 'parts': [ + selectionCommentPart(snapshotPath), + ], + }, + session, + ); + final restartedSession = BridgeSession( + id: 'session-1', + vmServiceUri: session.vmServiceUri, + projectRoot: session.projectRoot, + deviceId: session.deviceId, + ); + + final restartedSessionResult = ingress.parseMessage( + { + 'parts': [ + selectionCommentPart(snapshotPath), + ], + }, + restartedSession, + ); + + expect(originalSessionResult, isA()); + expect( + (restartedSessionResult as RejectedChatIngressMessage).error, + 'invalid_chat_parts', + ); + }); }); } diff --git a/apps/web/src/app/AskUiWorkbench.tsx b/apps/web/src/app/AskUiWorkbench.tsx index d9c7b45..4b63ec5 100644 --- a/apps/web/src/app/AskUiWorkbench.tsx +++ b/apps/web/src/app/AskUiWorkbench.tsx @@ -88,6 +88,7 @@ export function AskUiWorkbench() { activeSelectionCommentId={selectionComments.activeSelectionCommentId} attachmentTokens={selectionComments.attachmentTokens} chatSessionState={chatSession} + isReadOnly={isReadOnly} isSelectWidgetActive={actions.topBarActionState.isSelectWidgetActive} onAttachmentTokenClick={selectionComments.handleAttachmentTokenClick} onSelectionCommentStateChange={ diff --git a/apps/web/src/chat/chatComposerState.test.ts b/apps/web/src/chat/chatComposerState.test.ts index 391161f..b3f6581 100644 --- a/apps/web/src/chat/chatComposerState.test.ts +++ b/apps/web/src/chat/chatComposerState.test.ts @@ -4,10 +4,14 @@ import test from 'node:test'; import { CHAT_COMPOSER_TEXT_LIMIT, getChatComposerState, + getChatComposerTextareaInputPolicy, getComposerTextAfterSendResult, shouldSubmitChatComposerKey, } from './chatComposerState.ts'; -import { getInitialChatSessionState } from './chatSessionState.ts'; +import { + getInitialChatSessionState, + reduceChatSessionDisconnected, +} from './chatSessionState.ts'; test('enables Chat send only while the Agent is ready with text or attachments', () => { const state = getInitialChatSessionState({ @@ -59,6 +63,58 @@ test('disables Chat send when Agent Status is not ready', () => { }); }); +test('disables Chat send after session events disconnect', () => { + const state = reduceChatSessionDisconnected( + getInitialChatSessionState({ + status: 'ok', + agentStatus: 'agent_ready', + readOnly: false, + messages: [], + }), + ); + + assert.deepEqual(getChatComposerState(state, 'Make it primary.'), { + canSend: false, + disabledReason: 'Agent Status is Waiting for agent.', + isTooLong: false, + }); +}); + +test('enables prepared composer text after Agent ready recovery without clearing it', () => { + const disconnected = reduceChatSessionDisconnected( + getInitialChatSessionState({ + status: 'ok', + agentStatus: 'agent_working', + readOnly: false, + messages: [], + }), + ); + const preparedText = 'Make it primary.'; + + assert.deepEqual(getChatComposerState(disconnected, preparedText), { + canSend: false, + disabledReason: 'Agent Status is Waiting for agent.', + isTooLong: false, + }); + + const recovered = getInitialChatSessionState({ + status: 'ok', + agentStatus: 'agent_ready', + readOnly: false, + messages: [], + }); + + assert.deepEqual(getChatComposerState(recovered, preparedText), { + canSend: true, + disabledReason: null, + isTooLong: false, + }); + assert.equal( + getComposerTextAfterSendResult(preparedText, false), + preparedText, + ); +}); + test('limits typed composer text to 4000 characters', () => { const state = getInitialChatSessionState({ status: 'ok', @@ -74,6 +130,12 @@ test('limits typed composer text to 4000 characters', () => { }); }); +test('lets typed composer text exceed the limit so inline validation can explain it', () => { + assert.deepEqual(getChatComposerTextareaInputPolicy(), { + maxLength: undefined, + }); +}); + test('Enter submits the Chat composer while Shift+Enter inserts a newline', () => { assert.equal(shouldSubmitChatComposerKey('Enter', false), true); assert.equal(shouldSubmitChatComposerKey('Enter', true), false); diff --git a/apps/web/src/chat/chatComposerState.ts b/apps/web/src/chat/chatComposerState.ts index 5947856..d0f24b7 100644 --- a/apps/web/src/chat/chatComposerState.ts +++ b/apps/web/src/chat/chatComposerState.ts @@ -11,6 +11,10 @@ export type ChatComposerState = { isTooLong: boolean; }; +export type ChatComposerTextareaInputPolicy = { + maxLength: number | undefined; +}; + /** * Derive Chat composer sendability from Chat session state and attachments. * @@ -83,6 +87,18 @@ export function getChatComposerState( }; } +/** + * Return DOM input constraints for the Chat composer textarea. + * + * The composer intentionally does not set `maxLength`: over-limit text must + * remain in local state so inline validation can explain why Send is disabled. + */ +export function getChatComposerTextareaInputPolicy(): ChatComposerTextareaInputPolicy { + return { + maxLength: undefined, + }; +} + /** * Return whether a textarea key press should submit Chat. * diff --git a/apps/web/src/chat/chatSessionState.test.ts b/apps/web/src/chat/chatSessionState.test.ts index b0c0c4a..19745fa 100644 --- a/apps/web/src/chat/chatSessionState.test.ts +++ b/apps/web/src/chat/chatSessionState.test.ts @@ -80,6 +80,44 @@ test('applies Chat History and Agent Status bridge events', () => { ]); }); +test('keeps read-only Chat sessions observable while applying bridge events', () => { + const initial = getInitialChatSessionState({ + status: 'ok', + agentStatus: 'waiting_for_agent', + readOnly: true, + messages: [], + }); + + const nextState = reduceChatSessionBridgeEvent(initial, { + type: 'chat_snapshot', + sessionId: 'session-1', + payload: { + agentStatus: 'agent_ready', + messages: [ + { + id: 'message-1', + role: 'agent', + text: 'Ready.', + }, + ], + }, + }); + + assert.deepEqual(nextState, { + status: 'ready', + agentStatus: 'agent_ready', + readOnly: true, + connectionWarning: null, + messages: [ + { + id: 'message-1', + role: 'agent', + text: 'Ready.', + }, + ], + }); +}); + test('replays queued bridge events after loading the initial Chat snapshot', () => { assert.deepEqual( getInitialChatSessionStateWithQueuedEvents( @@ -151,6 +189,45 @@ test('maps session event disconnect to Waiting for agent with a warning', () => }); }); +test('clears the session event disconnect warning when bridge events resume', () => { + const disconnected = reduceChatSessionDisconnected( + getInitialChatSessionState({ + status: 'ok', + agentStatus: 'agent_working', + readOnly: false, + messages: [ + { + id: 'message-1', + role: 'user', + text: 'Make it primary.', + }, + ], + }), + ); + + const reconnected = reduceChatSessionBridgeEvent(disconnected, { + type: 'agent_status_changed', + sessionId: 'session-1', + payload: { + agentStatus: 'agent_ready', + }, + }); + + assert.deepEqual(reconnected, { + status: 'ready', + agentStatus: 'agent_ready', + readOnly: false, + connectionWarning: null, + messages: [ + { + id: 'message-1', + role: 'user', + text: 'Make it primary.', + }, + ], + }); +}); + test('adds a temporary Agent working placeholder to visible Chat History', () => { const state = getInitialChatSessionState({ status: 'ok', diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 57c27a2..053149f 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1,4 +1,4 @@ -import { CHAT_COMPOSER_TEXT_LIMIT } from '../../chat/chatComposerState'; +import { getChatComposerTextareaInputPolicy } from '../../chat/chatComposerState'; import type { ChatSessionState } from '../../chat/chatSessionState'; import type { SelectionCommentAttachmentToken } from '../../selection-comments/selectionCommentState'; import type { useChatComposerFlow } from './useChatComposerFlow'; @@ -20,6 +20,8 @@ export function ChatComposer({ placeholder: string; sessionId: string | null; }) { + const inputPolicy = getChatComposerTextareaInputPolicy(); + return (
{ composer.handleComposerTextChange(event.target.value); }} diff --git a/apps/web/src/components/chat/ChatPanel.tsx b/apps/web/src/components/chat/ChatPanel.tsx index f6cd1c9..bbbc01e 100644 --- a/apps/web/src/components/chat/ChatPanel.tsx +++ b/apps/web/src/components/chat/ChatPanel.tsx @@ -24,6 +24,7 @@ export function ChatPanel({ activeSelectionCommentId, attachmentTokens, chatSessionState, + isReadOnly, isSelectWidgetActive, onAttachmentTokenClick, onSelectionCommentStateChange, @@ -36,6 +37,7 @@ export function ChatPanel({ activeSelectionCommentId: string | null; attachmentTokens: SelectionCommentAttachmentToken[]; chatSessionState: ChatSessionState; + isReadOnly: boolean; isSelectWidgetActive: boolean; onAttachmentTokenClick: (token: SelectionCommentAttachmentToken) => void; onSelectionCommentStateChange: Dispatch>; @@ -49,6 +51,7 @@ export function ChatPanel({ const selectionComments = useSelectionCommentPanelFlow({ activeSelectionCommentId, attachmentTokens, + isReadOnly, isSelectWidgetActive, onSelectionCommentStateChange, selectedWidget, diff --git a/apps/web/src/components/chat/SelectedWidgetSection.tsx b/apps/web/src/components/chat/SelectedWidgetSection.tsx index 487ad0b..55ff43c 100644 --- a/apps/web/src/components/chat/SelectedWidgetSection.tsx +++ b/apps/web/src/components/chat/SelectedWidgetSection.tsx @@ -1,4 +1,4 @@ -import { SELECTION_COMMENT_TEXT_LIMIT } from '../../selection-comments/selectionCommentState'; +import { getSelectionCommentTextareaInputPolicy } from '../../selection-comments/selectionCommentState'; import type { useSelectionCommentPanelFlow } from './useSelectionCommentPanelFlow'; type SelectionCommentPanelFlow = ReturnType< @@ -15,6 +15,7 @@ export function SelectedWidgetSection({ title: string; }) { const panelTarget = selectionComments.panelTarget; + const inputPolicy = getSelectionCommentTextareaInputPolicy(); return (
@@ -61,7 +62,8 @@ export function SelectedWidgetSection({