Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
3772cf0
Input delivery reads through Core's one owner-live operation (agent-c…
trissim Oct 28, 2026
0be58d8
Native Toad reads input delivery from the live owner queue (agent-com…
trissim Oct 28, 2026
a9a330b
Notification views build a root-free operation; one envelope binds th…
trissim Oct 28, 2026
54db5d1
Shutdown tests hold the real observer child; paint fixtures read the …
trissim Oct 28, 2026
cdd6e15
Owner presentation reads leave their worker as the published projecti…
trissim Oct 28, 2026
9b6c482
Commander detail reads through the shared comms_commander_unit operat…
trissim Oct 28, 2026
6f41bc6
The goal snapshot reads through the shared comms_owner_goal operation…
trissim Oct 28, 2026
9572b2f
Transcript reads carry the shared comms_transcript_page operation (#1…
trissim Oct 28, 2026
435fd2e
Merge commit '61fddea3620bd721d4dd7a3dbd1b356a4d45a26a' into refactor…
Oct 28, 2026
ef21cbf
mypy reads Toad's declared source roots, src and tools
trissim Oct 29, 2026
44abe7c
Share source-owned queue inspection across native and bus composers
trissim Oct 29, 2026
9494d86
Declare queue capabilities and remove duplicated title facts
trissim Oct 29, 2026
d89d327
Merge commit 'ef21cbf251516e2ad486e38f3531fc50f4245786' into batch/in…
trissim Oct 29, 2026
47b16cc
Merge commit '9494d8691' into batch/integ-20261029-toad
trissim Oct 29, 2026
b7ef9e3
Merge commit 'c28587bce6da1cbf56caf9e49fc7ca3eb89ca060' into batch/in…
trissim Oct 29, 2026
0a757e5
Read queue references from the root-free notification operation
trissim Oct 29, 2026
ece410b
Merge commit '0a757e54b666d3c11f64fe034933f19856b3d37b' into batch/in…
trissim Oct 29, 2026
5f72bcf
Retain actual Core display scope through notification observation
trissim Oct 29, 2026
4debca4
Close captured viewer and matched empty-feedback controls
trissim Oct 29, 2026
7fb7e45
Merge remote-tracking branch 'origin/main' into work/message-state--P…
trissim Oct 29, 2026
27999b1
Keep queue refusals typed and source identity lifetime-owned
trissim Oct 29, 2026
55b055b
Do not describe an unconfirmed queue action as definitely refused
trissim Oct 29, 2026
d92ac58
Read queue viewer projection from its shared display owner
trissim Oct 29, 2026
376596e
Merge origin/main
trissim Oct 29, 2026
865fc89
Integrate #1317 (P904 queue UI) into the Toad queue/notification pair
trissim Oct 29, 2026
1247a59
Page transcript reads by PagePosition and PageLimit members
trissim Oct 29, 2026
3cb4850
Make queue UI consume captured evidence and source-owned actions
trissim Oct 29, 2026
7d8ce92
Merge remote-tracking branch 'origin/main' into fix/queue-toad
trissim Oct 29, 2026
20a96a5
Choose painted reads before work and acquire queue evidence lazily
trissim Oct 29, 2026
599d343
Keep queue presentation on its captured evidence
trissim Oct 29, 2026
3a864eb
Type conversation inputs and remove queue UI import cycles
trissim Oct 29, 2026
90f0b87
Merge commit '3a864eb45855f80f7271582ae98ab33404bc844f' into HEAD
trissim Oct 30, 2026
9dff68c
Merge remote-tracking branch 'origin/main' into fork/integ-1319
trissim Oct 30, 2026
d314eca
Combine toad#1319 (queue presentation) onto main
trissim Oct 30, 2026
b02bd55
Merge remote-tracking branch 'origin/main' into HEAD
trissim Oct 30, 2026
db7a1be
Merge remote-tracking branch 'origin/main' into claude-code/toad1359-…
trissim Oct 30, 2026
01ffe21
Merge remote-tracking branch 'origin/batch/integ-3566-toad' into clau…
trissim Oct 30, 2026
daf2c1f
Import FrozenRecipient from its own Core module
trissim Oct 30, 2026
763b6b8
Merge main into the queue carrier
trissim Oct 30, 2026
1143a72
Pass the read page to its wire fragments
trissim Oct 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/toad/acp/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
from agent_comms.goal_presentation import GoalSnapshot, IdleGoalExecution
from agent_comms.goals import Goal, GoalDeclaration, NoGoal
from agent_comms.goal_history import RecordedGoalHistory
from agent_comms.ui_model.observation import ReadOwnerGoal
from agent_comms.order_tools import OwnerGoalRead
from agent_comms.ui_model.observation import ReadOwnerOperation

from toad import paths
from toad.acp.agent_configuration import (
Expand Down Expand Up @@ -238,7 +239,7 @@ async def get_goal_snapshot(self) -> GoalSnapshot:
authority = ClientSessionRequest(self, self.session_id)
consumer = OwnerSnapshotConsumer(self, self.session_id, turn_token=self.presentation.turns.sequence)
snapshot = await self.controller.transcripts.read(
ReadOwnerGoal(coordination.wire_root, coordination.thread))
ReadOwnerOperation(coordination.wire_root, coordination.thread, operation=OwnerGoalRead()))
self.controller.require_owner(coordination, authority)
for fact in snapshot.meta.batch.updates:
consumer.dispatch_sync(fact)
Expand Down
49 changes: 25 additions & 24 deletions src/toad/acp/agent_controller.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""ACP operations remain owned by the controller after their view retires."""

from __future__ import annotations
Expand All @@ -11,6 +11,7 @@
from acp.exceptions import RequestError
from agent_comms.acp_extension import (
ClearQueueRequest,
CommsRequest,
CompactRequest,
CoordinationChangedUpdate,
InputFailedUpdate,
Expand All @@ -23,16 +24,19 @@
from agent_comms.coordination_errors import Refused
from agent_comms.runtime import RuntimeConnection, socket_path
from agent_comms.transcripts import TranscriptReadIdentity
from agent_comms.ui_model.delivery import DeliveryView
from agent_comms.ui_model.observation import ReadDeliveryOverview
from agent_comms.order_tools import InputDeliveryRead
from agent_comms.ui_model.delivery import DeliveryView, ReadDeliveryView
from agent_comms.ui_model.observation import ReadOwnerOperation

from toad.acp.client_session import ClientSessionRequest
from toad.acp.client_session import ClientSessionRequest, QueueSessionRequest
from toad.acp.status import StopReason
from toad.core import events as core_events
from toad.core.events import LogAgentFail
from toad.plan import PlanItem
from toad.surface_binding import DetachedSurfaceBinding, SurfaceBinding

from toad.acp.queue_attachment import QueueActionRefused

from .prompt import build as build_prompt
from .session_output import SessionOutput
from .terminal_owner import OperationalTerminalOwner
Expand Down Expand Up @@ -270,9 +274,9 @@
if submission is not None:
self._deferred_submissions.discard(submission)

async def submit_blocks(self, content: list[dict[str, str]], command: ClearQueueRequest | None = None):
async def submit_blocks(self, content: list[dict[str, str]], command: CommsRequest):
agent = self.agent
authority = ClientSessionRequest(agent, agent.session_id)
authority = QueueSessionRequest(agent, agent.session_id)
return await self.operate(
self._prompt_operation(
partial(self._prompt, content, command, authority, agent.queue_attachment.scope), authority
Expand All @@ -285,11 +289,11 @@
def require():
authority.require()
if not agent.queue_attachment.accepts_request(queue_scope):
raise ValueError("The queued input owner changed before submission; inspect the current queue.")
raise QueueActionRefused("The queued input owner changed before submission; inspect the current queue.")

try:
result = await agent.process.prompt(
agent, content, encode_request(command) if command is not None else {}, require
agent, content, encode_request(command), require
)
except RequestError as error:
# The agent answered: this request failed and its session stays open.
Expand All @@ -301,15 +305,15 @@
report = agent.session.record_failure
else:
if authority.retired:
return None
return authority.unconfirmed("the captured ACP session retired before its reply")
return StopReason.member_named(result.stop_reason)
if authority.retired:
return None
return authority.unconfirmed("the captured ACP session retired during the request")
failure = request_failure.failure
self._prompt_failed(
command, authority, queue_scope, failure, report, published=request_failure.notification_published
)
return None
return authority.unconfirmed(failure.feedback)

def _prompt_failed(
self,
Expand All @@ -322,7 +326,7 @@
published: bool = False,
) -> None:
agent = self.agent
user_text = command.draft_text if command is not None else None
user_text = command.draft_text
if user_text:
agent.events.publish(
core_events.CommsUpdated(
Expand All @@ -338,19 +342,16 @@
async def clear_queue(self) -> None:
await self.submit_blocks([{"type": "text", "text": " "}], ClearQueueRequest())

async def send_now(self) -> bool:
authority = ClientSessionRequest(self.agent, self.agent.session_id)
async def send_now(self) -> None:
authority = QueueSessionRequest(self.agent, self.agent.session_id)
queue_scope = self.agent.queue_attachment.scope
if pending := tuple(self._deferred_submissions):
await asyncio.gather(*(asyncio.shield(task) for task in pending))
return (
await self.operate(
self._prompt_operation(
partial(self._prompt, [{"type": "text", "text": " "}], SendNowRequest(), authority, queue_scope),
authority,
)
await self.operate(
self._prompt_operation(
partial(self._prompt, [{"type": "text", "text": " "}], SendNowRequest(), authority, queue_scope),
authority,
)
is not None
)

async def compact_context(self, instructions=None):
Expand Down Expand Up @@ -427,11 +428,11 @@
if coordination is None:
return DeliveryView()
authority = ClientSessionRequest(self.agent, self.agent.session_id)
delivery = await self.transcripts.read(
ReadDeliveryOverview(coordination.wire_root, coordination.thread, include_history=include_history)
)
overview = await self.transcripts.read(ReadOwnerOperation(
coordination.wire_root, coordination.thread, InputDeliveryRead(include_history=include_history),
))
self.require_owner(coordination, authority)
return delivery
return ReadDeliveryView(overview)

async def dismiss_historical_inputs(self):
return await self.request_owner("dismiss_historical_inputs")
13 changes: 12 additions & 1 deletion src/toad/acp/client_session.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""One captured session authority for ACP client-side effects."""
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, NoReturn
from .queue_attachment import QueueActionRefused

if TYPE_CHECKING:
from .agent import Agent
Expand Down Expand Up @@ -34,6 +35,16 @@
if not self.current:
raise RequestError.invalid_params({'details': 'ACP client request belongs to a retired session'})

def unconfirmed(self, reason: str) -> None:
"""Ordinary input failure is presented by its existing failure publication."""


class QueueSessionRequest(ClientSessionRequest):
"""A queue mutation requires confirmation; an absent reply is never success."""

def unconfirmed(self, reason: str) -> NoReturn:
raise QueueActionRefused(f"Queue action not confirmed: {reason}. Inspect the queue before another action.")


class ClientRequestOwner:
def __init__(self, agent):
Expand Down
4 changes: 4 additions & 0 deletions src/toad/acp/queue_attachment.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@
from .projection_attachment import ProjectionAttachment


class QueueActionRefused(Exception):
"""The captured queue cannot confirm or authorize this action; never replay it."""


class QueueAttachment(ProjectionAttachment):
def __init__(self):
super().__init__()
Expand Down
33 changes: 15 additions & 18 deletions src/toad/acp/transcript_reader.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""A view's reads, answered by Core's observation service.

The UI process does not read Core's transcript stores or ask the live owner
Expand All @@ -16,6 +16,7 @@
from functools import partial

from agent_comms.acp_extension import TranscriptDraftUpdate, TranscriptSnapshotUpdate
from agent_comms.transcripts import AtFrontier, LatestPage, LivePageSource, PageLimit, PagePosition, PageSource
from agent_comms.thread_presentation import ThreadPresentation
from agent_comms.ui_model.observation import (
AwaitedRead,
Expand Down Expand Up @@ -68,35 +69,31 @@
self,
scope: TranscriptScope,
*,
before=None,
after=None,
through=None,
historical_source: str | None = None,
position: PagePosition = LatestPage(),
limit: PageLimit = AtFrontier(),
page_source: PageSource = LivePageSource(),
):
snapshot = await self.snapshot(
scope,
before=before,
after=after,
through=through,
historical_source=historical_source,
position=position,
limit=limit,
page_source=page_source,
)
return snapshot.page

async def snapshot(
self,
scope: TranscriptScope,
*,
before=None,
after=None,
through=None,
historical_source: str | None = None,
position: PagePosition = LatestPage(),
limit: PageLimit = AtFrontier(),
page_source: PageSource = LivePageSource(),
) -> TranscriptSnapshotUpdate:
return await self.read(
scope.snapshot_request(
before=before,
after=after,
through=through,
historical_source=historical_source,
position=position,
limit=limit,
page_source=page_source,
)
)

Expand All @@ -105,6 +102,6 @@
scope: TranscriptScope,
previous: TranscriptPublicationState,
*,
after=None,
position: PagePosition = LatestPage(),
) -> TranscriptSnapshotUpdate | TranscriptDraftUpdate:
return await self.read(scope.update_request(previous, after=after))
return await self.read(scope.update_request(previous, position=position))
3 changes: 0 additions & 3 deletions src/toad/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,6 @@ def accepts_failed_input(self, message):
else True
)

def send_now(self, owner):
return owner.send_bound_now(self)

def invalidate_observation(self, observation: SessionObservation) -> None:
observation.schedule(self)

Expand Down
3 changes: 0 additions & 3 deletions src/toad/agent_binding.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,9 +178,6 @@ def begin_submission(self, owner: ConversationSubmissions, submission: AgentInpu
def accepts_failed_input(self, message: CoreEventMessage) -> bool:
return False

def send_now(self, owner: ConversationSubmissions) -> None:
"""No remote queue is bound."""

async def get_goal_history(self, goal_id: str) -> RecordedGoalHistory:
return RecordedGoalHistory.empty()

Expand Down
44 changes: 42 additions & 2 deletions src/toad/agent_presentation.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
"""Agent-owned optional capabilities and derived attachment presentation."""
from __future__ import annotations

from abc import abstractmethod
from dataclasses import dataclass
from pathlib import Path
from agent_comms.declared_family import DeclaredFamily
from agent_comms.acp_extension import PendingQueueProjection, QueueProjection
from agent_comms.acp_extension import NoQueueScope, PendingQueueProjection, QueueProjection, QueueScope
from toad.private_native_cursor import CursorStatus, HiddenCursorStatus
from toad.conversation_turn import TurnBinding, LocalTurnBinding, ManagedTurnBinding
from toad.acp.queue_attachment import QueueActionRefused
from toad.slash_command import AgentAdvertisedCommand


@dataclass(frozen=True)
Expand Down Expand Up @@ -38,6 +42,21 @@ def queue_supported(self) -> bool:
def queue(self):
"""The actual source owns its input queue projection."""

@property
def queue_scope(self) -> QueueScope:
return NoQueueScope()

@property
def queue_identity(self) -> str:
return "No live ACP queue attachment"

@property
def queue_actions_available(self) -> bool:
return False

def require_queue_action(self, scope: QueueScope) -> None:
raise QueueActionRefused("No live ACP queue authority is available")

@property
def prompt_in_flight(self):
return 0
Expand Down Expand Up @@ -74,7 +93,6 @@ def auth_methods(self):

@property
def commands(self):
from toad.slash_command import AgentAdvertisedCommand
return tuple(AgentAdvertisedCommand.from_acp(record)
for record in self.agent.controller.commands)

Expand All @@ -87,6 +105,28 @@ def queue_supported(self) -> bool:
"""Advertisement belongs to the original coordination declaration."""
return self.agent.coordination is not None

@property
def queue_scope(self) -> QueueScope:
return self.agent.queue_attachment.scope

@property
def queue_identity(self) -> str:
attachment = self.agent.queue_attachment
return attachment.state.project(
attachment.current,
available=lambda current: f"Scope {current.scope!r} · revision {current.revision}",
pending="Checking queue attachment",
unavailable="Queue attachment unavailable",
)

@property
def queue_actions_available(self) -> bool:
return self.queue_supported and self.agent.queue_attachment.accepts_request(self.queue_scope)

def require_queue_action(self, scope: QueueScope) -> None:
if not self.queue_supported or not self.agent.queue_attachment.accepts_request(scope):
raise QueueActionRefused("Queue attachment changed or is unavailable; inspect the current queue again")

@property
def uses_managed_turns(self):
return self.turns.managed
Expand Down
19 changes: 8 additions & 11 deletions src/toad/conversation_kind.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@
from agent_comms.thread_identity import ThreadIncarnation
from textual.app import ComposeResult

from toad.conversation_submission import ConversationSubmissions
from toad.widgets.channel_prompt import ChannelPrompt
from toad.widgets.prompt import MessagePrompt
from toad.status_thread import NoStatusThread, StatusThread
from toad.widgets.channel_activity import ChannelActivity
from toad.widgets.session_details import ConversationSessionDetails
Expand Down Expand Up @@ -133,7 +136,7 @@ def active_members(self, model: SidebarModel):
def compose_activity(cls, view: CommsChatView) -> ComposeResult: ...

@abstractmethod
def prompt(self, project: Checkout): ...
def prompt(self, project: Checkout, *, submissions: ConversationSubmissions): ...

@classmethod
def seen_page(cls, history: WireHistory, seen):
Expand Down Expand Up @@ -206,18 +209,14 @@ async def status_text(self, comms: Comms, *, read_only: bool) -> str:
def initial_placeholder(self) -> str:
return f"Message {self.target.name}"

def prompt(self, project: Checkout):
from toad.widgets.channel_prompt import ChannelPrompt

return ChannelPrompt(project=project, placeholder=self.initial_placeholder)
def prompt(self, project: Checkout, *, submissions: ConversationSubmissions):
return ChannelPrompt(project=project, placeholder=self.initial_placeholder, submissions=submissions)

def read_only(self, comms: Comms) -> bool:
return comms.bus.require_channel(self.target.name).read_only

@classmethod
async def update_mentions(cls, view: CommsChatView, comms: Comms):
from toad.widgets.channel_prompt import ChannelPrompt

history = view.message_history
source = history.source_snapshot()
conversation = view.conversation
Expand Down Expand Up @@ -293,10 +292,8 @@ def compose_activity(cls, view) -> ComposeResult:
view.thread_activity = ConversationSessionDetails(view.conversation.peer)
yield view.thread_activity

def prompt(self, project: Checkout):
from toad.widgets.prompt import MessagePrompt

return MessagePrompt(project=project)
def prompt(self, project: Checkout, *, submissions: ConversationSubmissions):
return MessagePrompt(project=project, submissions=submissions)

async def status_text(self, comms: Comms, *, read_only: bool) -> str:
def read():
Expand Down
Loading
Loading