Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
13 changes: 12 additions & 1 deletion src/Capacitor.App/ViewModels/RunRow.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ public sealed class RunRow : ReactiveObject {
bool _isBackground;
string? _agentId;
bool _outcomeUnknown;
bool _openable = true;
RunState _state = RunState.Running;
string _stateText = "";

Expand Down Expand Up @@ -43,7 +44,17 @@ private set {
}

/// Whether the subagent's own transcript can be opened from this row.
public bool CanOpen => Kind == RunKind.Agent && AgentId is not null;
public bool CanOpen => Kind == RunKind.Agent && AgentId is not null && _openable;

/// Set by the owning lane: false where it has no way to read a subagent's transcript.
internal bool Openable {
get => _openable;
set {
if (_openable == value) return;
_openable = value;
this.RaisePropertyChanged(nameof(CanOpen));
}
}

internal void Identify(string agentId) {
if (Kind == RunKind.Agent) AgentId = agentId;
Expand Down
13 changes: 12 additions & 1 deletion src/Capacitor.App/ViewModels/SessionRuns.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ public sealed class SessionRuns(TimeProvider time) {
readonly int[] _counts = new int[Enum.GetValues<RunState>().Length];
int _runningBackground;
bool _sessionOver;
bool _opensSubagents = true;

public IAvaloniaReadOnlyList<RunRow> Rows => _rows;
/// The rows of Rows that present as running, in arrival order.
Expand All @@ -52,6 +53,16 @@ public bool SessionOver {
}
}

/// Whether this lane can open a subagent's own transcript; a row offers to open only when it can.
public bool OpensSubagents {
get => _opensSubagents;
set {
if (_opensSubagents == value) return;
_opensSubagents = value;
foreach (var row in _rows) row.Openable = value;
}
}

public void Apply(ChatProjectionResult projection) => ApplyAll([projection]);

/// One refresh per batch: a replayed command that started and finished inside it must never
Expand Down Expand Up @@ -118,7 +129,7 @@ void Start(RunSignal.Started started) {
}

RunRow Add(RunSignal.Started started) {
var row = new RunRow(started.CallId, started.Name, started.Description, started.At, started.Kind);
var row = new RunRow(started.CallId, started.Name, started.Description, started.At, started.Kind) { Openable = _opensSubagents };
_byCall[started.CallId] = row;
_rows.Add(row);
if (row.Kind == RunKind.Agent) {
Expand Down
12 changes: 10 additions & 2 deletions src/Capacitor.App/ViewModels/WorkspaceViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,11 @@ public WorkspaceViewModel(
.Where(state => state == AttachState.Connected).ObserveOn(RxSchedulers.MainThreadScheduler)
.Subscribe(_ => PullRequests?.Reconnected()).DisposeWith(_disposables);

presence.Select(p => p.Dto).Subscribe(dto => _latestDto = dto).DisposeWith(_disposables);
presence.Select(p => p.Dto).Subscribe(dto => {
_latestDto = dto;
// Only Claude Code files a subagent's transcript beside its parent's, so only its rows open here.
runs.OpensSubagents = dto is not null && FilesSubagentsBesideItself(dto);
}).DisposeWith(_disposables);
Comment thread
realtonyyoung marked this conversation as resolved.
Resolved

// Replays through presence, which the cards pipeline relies on: its filter admits nothing
// until a first session id arrives.
Expand Down Expand Up @@ -398,7 +402,7 @@ internal static string CheckoutLabelFor(AgentStatusDto? dto) {
/// A subagent's transcript sits beside its parent's on this machine, written as it runs.
void OpenSubagent(RunRow row) {
// Only Claude Code files a subagent's transcript beside its parent's.
if (row.AgentId is not { } subagentId || _latestDto is not { TranscriptPath: { } parentPath, Vendor: "claude" } dto
if (row.AgentId is not { } subagentId || _latestDto is not { TranscriptPath: { } parentPath } dto || !FilesSubagentsBesideItself(dto)
|| TranscriptChat.ForSubagent(dto.Vendor) is not { } projection) return;
var path = ClaudeSubagentFiles.TranscriptPath(parentPath, subagentId);
var logged = new System.Collections.Concurrent.ConcurrentDictionary<string, byte>(StringComparer.Ordinal);
Expand All @@ -410,6 +414,10 @@ void OpenSubagent(RunRow row) {
ActiveTab = WorkspaceTab.Chat;
}

/// Claude Code's own transcript, known and not a journal the daemon writes for it.
static bool FilesSubagentsBesideItself(AgentStatusDto dto) =>
dto is { Vendor: "claude", TranscriptPath: not null } && dto.TranscriptFormat != TranscriptFormats.Envelopes;

void CloseSubagent() => ReplaceSubagent(null);

void ReplaceSubagent(SubagentPaneViewModel? next) {
Expand Down
24 changes: 23 additions & 1 deletion src/Capacitor.Cli.Core/EnvelopeJournalProjection.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
using System.Globalization;

namespace Capacitor.Cli.Core;

/// Journal lines are already envelopes: no vendor rules, no dedupe, one envelope per valid line.
/// Reads the daemon's ACP envelope journal, one envelope per line.
public sealed class EnvelopeJournalProjection : IChatTranscriptProjection {
sealed class StatelessContext : TranscriptContext;

Expand All @@ -12,4 +14,24 @@ public IReadOnlyList<AcpEventEnvelope> Project(string line, int lineNumber, Date
// No line number in the message: the reader logs one failure per distinct message, so a
// per-line message would log a corrupt journal once per line.
: throw new FormatException($"not a v{EnvelopeJournalFormat.SupportedContractVersion} envelope");

/// The runtime journals its subagents' start and end as it sees them, the records the server writes the same rows from.
public ChatProjectionResult ProjectWithInputs(string line, int lineNumber, DateTimeOffset receivedAt, TranscriptContext context) {
var envelopes = Project(line, lineNumber, receivedAt, context);
return new(envelopes,
[.. envelopes.Where(e => e.Kind == AcpEventKind.UserMessage && e.Text is not null).Select(e => e.Text!)],
[.. envelopes.SelectMany(e => Runs(e, receivedAt))]);
}

static IEnumerable<RunSignal> Runs(AcpEventEnvelope envelope, DateTimeOffset receivedAt) => envelope switch {
{ Kind: AcpEventKind.SubagentStarted, AgentId: { Length: > 0 } agentId } =>
TranscriptChat.SubagentStart(agentId, envelope.AgentType, At(envelope, receivedAt), rules: null),
{ Kind: AcpEventKind.SubagentCompleted, AgentId: { Length: > 0 } agentId } =>
[new RunSignal.Finished(null, agentId, null, At(envelope, receivedAt))],
_ => [],
};

static DateTimeOffset At(AcpEventEnvelope envelope, DateTimeOffset receivedAt) =>
envelope.TimestampIso is { } iso && DateTimeOffset.TryParse(iso, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var at)
? at : receivedAt;
}
2 changes: 2 additions & 0 deletions src/Capacitor.Cli.Core/Harness/Claude/ClaudeChatRules.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ public sealed partial class ClaudeChatRules : IChatDisplayRules {

readonly bool _ownTranscript;

public bool StartsSubagentRows => true;

const string StoppedTaskMessage = "Successfully stopped task";
const string HandbackPointer = "This agent's report was delivered to you as a message";

Expand Down
15 changes: 14 additions & 1 deletion src/Capacitor.Cli.Core/TranscriptChat.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ public interface IChatDisplayRules {

/// Subagent facts read off the raw envelope, whatever Filter decides for it.
IReadOnlyList<RunSignal> Runs(CanonicalEvent evt, AcpEventEnvelope raw) => [];

/// True when the vendor's own records start its subagent rows, so a start record only names one of them.
bool StartsSubagentRows => false;
}

/// The chat's view of a transcript: the leaf projection, the envelope mapping, one vendor's rules.
Expand Down Expand Up @@ -73,7 +76,7 @@ public static ChatProjectionResult Project(CanonicalEvent evt, IChatDisplayRules
return new([], [], [new RunSignal.Finished(null, completed.AgentId, null, evt.Timestamp)]);
// Written from the subagent-start hook, which names the agent but not its call.
if (evt.Payload is SubagentStarted started)
return new([], [], started.AgentId is { Length: > 0 } agentId ? [new RunSignal.Identified(null, agentId)] : []);
return new([], [], started.AgentId is { Length: > 0 } agentId ? SubagentStart(agentId, started.AgentType, evt.Timestamp, rules) : []);
Comment thread
realtonyyoung marked this conversation as resolved.
Resolved
var envelopes = TranscriptEnvelopes.From(evt);
if (envelopes.Count == 0) return new([], [], []);
var parts = new List<ChatProjectionResult>(envelopes.Count);
Expand All @@ -91,4 +94,14 @@ public static ChatProjectionResult Project(CanonicalEvent evt, IChatDisplayRules
}
return ChatProjectionResult.Combine(parts);
}

/// A subagent's start record. Where the vendor's own records start no row, it starts one keyed by the agent, so the
/// agent's completion finds it; otherwise it only names the row the vendor's call started.
public static IReadOnlyList<RunSignal> SubagentStart(string agentId, string? agentType, DateTimeOffset at, IChatDisplayRules? rules) {
if (rules?.StartsSubagentRows == true) return [new RunSignal.Identified(null, agentId)];

// Keyed by the start too: an id can start a second run, and the record's own time keeps a replay one row.
var key = $"agent:{agentId}:{at.UtcTicks}";
return [new RunSignal.Started(key, agentType is { Length: > 0 } type ? type : "agent", "", at), new RunSignal.Identified(key, agentId)];
}
}
41 changes: 41 additions & 0 deletions test/Capacitor.App.Tests.Unit/SessionRunsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,47 @@ public async Task A_failed_result_and_a_failed_notification_read_as_failed() {
await Assert.That(s.RunningCount).IsEqualTo(0);
}

[Test]
public async Task A_start_record_row_is_named_and_ended_by_its_agent() {
var s = new SessionRuns(Clock());
var start = Signals([.. TranscriptChat.SubagentStart("a1", "explorer", T0, rules: null)]);
s.Apply(start);
s.Apply(start);

await Assert.That(Only(s).AgentId).IsEqualTo("a1");
await Assert.That(Only(s).CanOpen).IsTrue();
await Assert.That(s.RunningCount).IsEqualTo(1);

s.Apply(Signals(Finished(null, "a1", outcome: null, at: T0.AddSeconds(62))));
await Assert.That(Only(s).State).IsEqualTo(RunState.Done);
await Assert.That(s.RunningCount).IsEqualTo(0);
}

[Test]
public async Task A_second_start_for_the_same_agent_is_its_own_row() {
var s = new SessionRuns(Clock());
s.Apply(Signals([.. TranscriptChat.SubagentStart("a1", "explorer", T0, rules: null)]));
s.Apply(Signals(Finished(null, "a1", outcome: null, at: T0.AddMinutes(1))));
s.Apply(Signals([.. TranscriptChat.SubagentStart("a1", "explorer", T0.AddMinutes(5), rules: null)]));

await Assert.That(s.Rows.Count).IsEqualTo(2);
await Assert.That(s.RunningCount).IsEqualTo(1);

s.Apply(Signals(Finished(null, "a1", outcome: null, at: T0.AddMinutes(6))));
await Assert.That(s.RunningCount).IsEqualTo(0);
}

/// A lane that cannot read a subagent's transcript must not offer a row that opens nothing.
[Test]
public async Task A_row_offers_to_open_only_where_the_lane_can_open_it() {
var s = new SessionRuns(Clock()) { OpensSubagents = false };
s.Apply(Signals([.. TranscriptChat.SubagentStart("a1", "explorer", T0, rules: null)]));
await Assert.That(Only(s).CanOpen).IsFalse();

s.OpensSubagents = true;
await Assert.That(Only(s).CanOpen).IsTrue();
}

[Test]
public async Task A_background_row_is_stopped_by_its_agent_id_alone() {
var s = new SessionRuns(Clock());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,24 @@ public async Task Context_is_stateless_and_reusable() {
await Assert.That(Sut.Project(line, 2, DateTimeOffset.UnixEpoch, context)).Count().IsEqualTo(1);
}

/// A hosted ACP agent's subagents reach the local lane only through these journal records.
[Test]
public async Task A_journaled_subagent_start_and_end_start_and_finish_its_row() {
var context = Sut.CreateContext("s", null);
var start = Sut.ProjectWithInputs(EnvelopeJournalFormat.Write(new AcpEventEnvelope(
Kind: AcpEventKind.SubagentStarted, AgentId: "a1", AgentType: "explorer", TimestampIso: "2026-10-21T09:00:00Z")), 1, DateTimeOffset.UnixEpoch, context);
var end = Sut.ProjectWithInputs(EnvelopeJournalFormat.Write(new AcpEventEnvelope(
Kind: AcpEventKind.SubagentCompleted, AgentId: "a1", TimestampIso: "2026-10-21T09:05:00Z")), 2, DateTimeOffset.UnixEpoch, context);

var started = (RunSignal.Started)start.Runs[0];
var finished = (RunSignal.Finished)end.Runs.Single();
await Assert.That(started.Name).IsEqualTo("explorer");
await Assert.That(started.At).IsEqualTo(new DateTimeOffset(2026, 10, 21, 9, 0, 0, TimeSpan.Zero));
await Assert.That(((RunSignal.Identified)start.Runs[1]).AgentId).IsEqualTo("a1");
await Assert.That(finished.AgentId).IsEqualTo("a1");
await Assert.That(finished.At).IsEqualTo(new DateTimeOffset(2026, 10, 21, 9, 5, 0, TimeSpan.Zero));
}

[Test]
public async Task Vendor_registry_is_unchanged() {
await Assert.That(TranscriptChat.For("claude")).IsNotNull();
Expand Down
35 changes: 26 additions & 9 deletions test/Capacitor.Cli.Core.Tests.Unit/TranscriptChatCanonicalTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,19 +39,36 @@ public async Task Claude_rules_hide_a_meta_message_and_acknowledge_no_input_for_
await Assert.That(result.SubmittedInputs).IsEmpty();
}

/// The server writes it from the subagent-start hook, which names the agent but not its call.
/// The server writes it from the subagent-start hook, which names the agent but not its call. Claude's own records
/// start the row, so the record only names it there and never adds a second.
[Test]
public async Task A_subagent_start_identifies_an_agent_with_no_call_under_any_rules() {
foreach (var rules in new IChatDisplayRules?[] { null, ClaudeChatRules.Instance }) {
var result = TranscriptChat.Project(Event(CanonicalEventTypes.SubagentStarted, new SubagentStarted { AgentId = "a1" }), rules);
await Assert.That(result.Envelopes).IsEmpty();
var identified = (RunSignal.Identified)result.Runs.Single();
await Assert.That(identified.CallId).IsNull();
await Assert.That(identified.AgentId).IsEqualTo("a1");
}
public async Task A_subagent_start_identifies_an_agent_with_no_call_where_the_vendor_starts_its_rows() {
var result = TranscriptChat.Project(Event(CanonicalEventTypes.SubagentStarted, new SubagentStarted { AgentId = "a1" }), ClaudeChatRules.Instance);
await Assert.That(result.Envelopes).IsEmpty();
var identified = (RunSignal.Identified)result.Runs.Single();
await Assert.That(identified.CallId).IsNull();
await Assert.That(identified.AgentId).IsEqualTo("a1");
await Assert.That(TranscriptChat.Project(Event(CanonicalEventTypes.SubagentStarted, new SubagentStarted()), ClaudeChatRules.Instance).Runs).IsEmpty();
}

[Test]
[Arguments("codex")]
[Arguments("gemini")]
public async Task A_subagent_start_starts_a_named_row_where_the_vendor_starts_none(string vendor) {
var at = new DateTimeOffset(2026, 10, 21, 9, 0, 0, TimeSpan.Zero);
var result = TranscriptChat.Project(
new CanonicalEvent(CanonicalEventTypes.SubagentStarted, new SubagentStarted { AgentId = "a1", AgentType = "explorer" }, Guid.NewGuid(), at),
TranscriptChat.RulesFor(vendor));

var started = (RunSignal.Started)result.Runs[0];
var identified = (RunSignal.Identified)result.Runs[1];
await Assert.That(started.Name).IsEqualTo("explorer");
await Assert.That(started.At).IsEqualTo(at);
await Assert.That(started.Kind).IsEqualTo(RunKind.Agent);
await Assert.That(identified.CallId).IsEqualTo(started.CallId);
await Assert.That(identified.AgentId).IsEqualTo("a1");
}

/// The server writes it from the subagent-stop hook, which says the subagent ended but not how.
[Test]
public async Task A_subagent_completion_finishes_by_agent_id_with_no_outcome_under_any_rules() {
Expand Down
Loading