Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7cb52927f2 | ||
|
|
2724447a51 | ||
|
|
979bfe2fbe | ||
|
|
8f96027878 | ||
|
|
b6f60bfbae | ||
|
|
a8a335eadb | ||
|
|
048e273ff9 | ||
|
|
beef2049ed | ||
|
|
c30592e6b6 | ||
|
|
abae81a0ce | ||
|
|
66a568818a | ||
|
|
f3d6146884 |
@@ -12,7 +12,14 @@
|
||||
"Bash(npm test:*)",
|
||||
"Bash(cargo check:*)",
|
||||
"Bash(cargo clippy:*)",
|
||||
"Bash(cargo test:*)"
|
||||
"Bash(cargo test:*)",
|
||||
"Bash(source:*)",
|
||||
"Bash(git remote get-url:*)",
|
||||
"Bash(git push:*)",
|
||||
"Bash(git checkout:*)",
|
||||
"Bash(git -C /home/naps62/tea/maestro merge grazioso-2 --no-edit)",
|
||||
"Bash(git -C /home/naps62/tea/maestro push)",
|
||||
"Bash(curl:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ on:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
@@ -2916,17 +2916,16 @@ async fn mark_session_dead_with_worktree_settlement(
|
||||
// Release anyone attached; the scrollback survives for a later
|
||||
// attach.
|
||||
record.bridge.seal();
|
||||
let session = record.session.clone();
|
||||
let worktree = record
|
||||
.worktree_base_repo
|
||||
.clone()
|
||||
.zip(record.worktree_path.clone());
|
||||
Some((session, worktree))
|
||||
Some(worktree)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
let Some((mut session, worktree)) = flipped else {
|
||||
let Some(worktree) = flipped else {
|
||||
return false;
|
||||
};
|
||||
let stream = inner
|
||||
@@ -2943,9 +2942,6 @@ async fn mark_session_dead_with_worktree_settlement(
|
||||
if settle_worktree {
|
||||
if let Some(wt) = &worktree {
|
||||
if settle_worktree_on_exit(inner, id, wt).await {
|
||||
// The tree is settled (removed); a stale "in the way" would
|
||||
// describe something the session no longer holds.
|
||||
session.blocked_work = None;
|
||||
let mut sessions = inner.sessions.lock().await;
|
||||
if let Some(record) = sessions.get_mut(&id) {
|
||||
record.session.worktree = None;
|
||||
@@ -2957,13 +2953,17 @@ async fn mark_session_dead_with_worktree_settlement(
|
||||
}
|
||||
}
|
||||
// Settle has now been applied or refused either way (#115): from here on
|
||||
// its outcomes are safe to observe.
|
||||
{
|
||||
// its outcomes are safe to observe. Re-read the session from the record
|
||||
// so the sidecar write includes any updates (e.g. a haiku rename) that
|
||||
// landed between the initial clone and now.
|
||||
let session = {
|
||||
let mut sessions = inner.sessions.lock().await;
|
||||
if let Some(record) = sessions.get_mut(&id) {
|
||||
record.settled = true;
|
||||
}
|
||||
}
|
||||
let Some(record) = sessions.get_mut(&id) else {
|
||||
return false;
|
||||
};
|
||||
record.settled = true;
|
||||
record.session.clone()
|
||||
};
|
||||
|
||||
let _ = inner.sidecar.write(&session);
|
||||
let _ = inner.events.send(CatalogEvent::SessionUpdated { session });
|
||||
|
||||
@@ -11,14 +11,14 @@ use common::TmuxFixture;
|
||||
use maestro_daemon::sidecar::SidecarStore;
|
||||
use maestro_daemon::worktree::WorktreeManager;
|
||||
use maestro_daemon::SessionManager;
|
||||
use maestro_protocol::{display, CatalogEvent, CreateSessionRequest, NameSource};
|
||||
use maestro_protocol::{display, CatalogEvent, CreateSessionRequest, NameSource, SessionStatus};
|
||||
use tempfile::TempDir;
|
||||
use uuid::Uuid;
|
||||
|
||||
struct Fixture {
|
||||
manager: SessionManager,
|
||||
sidecar: SidecarStore,
|
||||
_tmux: TmuxFixture,
|
||||
tmux: TmuxFixture,
|
||||
_state_dir: TempDir,
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ fn fixture() -> Fixture {
|
||||
Fixture {
|
||||
manager,
|
||||
sidecar,
|
||||
_tmux: tmux,
|
||||
tmux,
|
||||
_state_dir: state_dir,
|
||||
}
|
||||
}
|
||||
@@ -171,3 +171,37 @@ async fn a_blank_name_changes_nothing() {
|
||||
let live = fx.sidecar.read(id).unwrap().unwrap();
|
||||
assert_eq!(display::name_source(&live), NameSource::Generated);
|
||||
}
|
||||
|
||||
/// A session that dies naturally (liveness watcher marks it dead) must not
|
||||
/// overwrite a haiku rename that landed before the death. Regression test for
|
||||
/// the stale-clone sidecar write in `mark_session_dead`.
|
||||
#[tokio::test]
|
||||
async fn session_death_preserves_the_haiku_name() {
|
||||
let fx = fixture();
|
||||
let id = spawn(&fx.manager, None).await;
|
||||
|
||||
fx.manager.claim_autoname(id).await.unwrap();
|
||||
fx.manager.apply_autoname(id, "haiku-name").await.unwrap();
|
||||
|
||||
// Verify the sidecar has Haiku before the session dies.
|
||||
let before = fx.sidecar.read(id).unwrap().unwrap();
|
||||
assert_eq!(display::name_source(&before), NameSource::Haiku);
|
||||
|
||||
// Kill the tmux session directly so the liveness watcher picks it up.
|
||||
fx.tmux.tmux.kill_session(id).expect("kill tmux directly");
|
||||
fx.manager
|
||||
.start_liveness_watcher(std::time::Duration::from_millis(25));
|
||||
|
||||
common::wait_for_async(std::time::Duration::from_secs(3), || async {
|
||||
fx.manager.status(id).await == Some(SessionStatus::Dead)
|
||||
})
|
||||
.await;
|
||||
|
||||
let after = fx.sidecar.read(id).unwrap().unwrap();
|
||||
assert_eq!(
|
||||
display::name_source(&after),
|
||||
NameSource::Haiku,
|
||||
"mark_session_dead must not overwrite a haiku rename in the sidecar"
|
||||
);
|
||||
assert_eq!(display::custom_name(&after), Some("haiku-name"));
|
||||
}
|
||||
|
||||
@@ -13,14 +13,14 @@ async fn embedded_index_is_real_spa_not_placeholder() {
|
||||
let html = String::from_utf8(body.to_vec()).unwrap();
|
||||
|
||||
assert!(
|
||||
html.contains(r#"id="app""#),
|
||||
html.contains(r#"id="root""#),
|
||||
"served index is missing the SPA mount point; got:\n{html}"
|
||||
);
|
||||
assert!(
|
||||
!html.contains("maestro is running"),
|
||||
"served index is the placeholder, not the built bundle"
|
||||
);
|
||||
// Stronger signal than `id="app"` (which appears in both source and built
|
||||
// Stronger signal than `id="root"` (which appears in both source and built
|
||||
// HTML): the vite build rewrites the entry to a hashed `/assets/index-<hash>`
|
||||
// bundle, whereas the un-built source references `/src/main.ts`. This catches
|
||||
// both an empty/placeholder embed and the staleness case.
|
||||
|
||||
@@ -59,7 +59,7 @@ async fn root_serves_the_spa_shell() {
|
||||
.map(|v| v.starts_with("text/html")),
|
||||
Some(true)
|
||||
);
|
||||
assert!(resp.text().await.unwrap().contains(r#"id="app""#));
|
||||
assert!(resp.text().await.unwrap().contains(r#"id="root""#));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -69,7 +69,7 @@ async fn client_side_route_falls_back_to_the_shell() {
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
assert!(resp.text().await.unwrap().contains(r#"id="app""#));
|
||||
assert!(resp.text().await.unwrap().contains(r#"id="root""#));
|
||||
}
|
||||
|
||||
/// An unmatched API path must 404 rather than fall through to the SPA shell:
|
||||
|
||||
@@ -194,12 +194,15 @@ choices, not daemon ones.
|
||||
appear *between* tool rows in the same turn are absorbed into the group
|
||||
(intermediate narration); the last `AgentText` with no subsequent tool stays
|
||||
outside as the prominent final answer. While a turn is still active
|
||||
(responding or thinking), the most recent intermediate `AgentText` or
|
||||
`AgentThought` in the group is kept outside as visible narration — replaced
|
||||
by the next text row when one arrives, and absorbed into the group when the
|
||||
turn ends. The header still reads "N tool calls" (counting only tool rows).
|
||||
Opening the group reveals tools and intermediate text in chronological
|
||||
order, with the text rendered in a subdued style.
|
||||
(responding or thinking), the most recent trailing text or thought row is
|
||||
kept outside as visible narration — the operator always sees the agent's
|
||||
latest reasoning without opening the group. It is replaced by the next text
|
||||
row when one arrives, and absorbed into the group when the turn ends (unless
|
||||
it is `AgentText`, which stays outside as the final answer). The collapsed
|
||||
header reads "N tool calls · Label 'arg'" where the label and argument come
|
||||
from the last tool row's `splitToolTitle` — the operator sees which command
|
||||
is current at a glance. Opening the group reveals tools and intermediate
|
||||
text in chronological order, with the text rendered in a subdued style.
|
||||
- The web client's user, agent, thought, tool-detail, and notice text blocks
|
||||
copy their complete source text when clicked or keyboard-activated.
|
||||
- The web client consumes `available_commands_update` notices as the live
|
||||
|
||||
@@ -175,7 +175,7 @@ describe("tool rows and activity groups", () => {
|
||||
chunk("agent:2", "agent_text", "the answer"),
|
||||
]);
|
||||
|
||||
const head = screen.getByText("2 tool calls").closest("button")!;
|
||||
const head = screen.getByText(/^2 tool calls/).closest("button")!;
|
||||
expect(head.getAttribute("aria-expanded")).toBe("false");
|
||||
// The final answer stays outside the group; the intermediate text is in it.
|
||||
expect(document.querySelector(".st-tool-group")!.contains(screen.getByText("the answer"))).toBe(false);
|
||||
|
||||
@@ -354,6 +354,8 @@ export const StructuredPane = forwardRef<StructuredPaneHandle, StructuredPanePro
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
toolCount={item.group.toolCount}
|
||||
lastToolLabel={item.group.lastToolLabel}
|
||||
lastToolArg={item.group.lastToolArg}
|
||||
expanded={expandedGroups.has(item.id)}
|
||||
onExpandedChange={(expanded) => setGroupExpanded(item.id, expanded)}
|
||||
>
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
// A turn's collapsed activity block: two or more tool rows after the same
|
||||
// `User` row, plus the narration absorbed between them. The header counts tool
|
||||
// rows only (docs/STRUCTURED-VIEW.md); opening it reveals tools and text in
|
||||
// chronological order.
|
||||
// rows only (docs/STRUCTURED-VIEW.md) and shows the last/current tool label so
|
||||
// the operator sees activity at a glance without expanding. Opening it reveals
|
||||
// tools and text in chronological order.
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export interface ToolGroupProps {
|
||||
id: string;
|
||||
toolCount: number;
|
||||
/** Label of the last (or running) tool, e.g. "Execute". */
|
||||
lastToolLabel: string;
|
||||
/** Argument of the last tool, e.g. "`git status`". */
|
||||
lastToolArg: string;
|
||||
expanded: boolean;
|
||||
onExpandedChange(expanded: boolean): void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function ToolGroup({ id, toolCount, expanded, onExpandedChange, children }: ToolGroupProps) {
|
||||
export function ToolGroup({ id, toolCount, lastToolLabel, lastToolArg, expanded, onExpandedChange, children }: ToolGroupProps) {
|
||||
const summary = lastToolArg
|
||||
? `${toolCount} tool calls · ${lastToolLabel} \u2018${lastToolArg}\u2019`
|
||||
: `${toolCount} tool calls`;
|
||||
return (
|
||||
<div className="st-tool-group" data-tool-group-id={id}>
|
||||
<button
|
||||
@@ -25,7 +33,7 @@ export function ToolGroup({ id, toolCount, expanded, onExpandedChange, children
|
||||
<span className="st-tool-group-glyph" aria-hidden="true">
|
||||
›
|
||||
</span>
|
||||
<span className="st-tool-group-title">{`${toolCount} tool calls`}</span>
|
||||
<span className="st-tool-group-title">{summary}</span>
|
||||
</button>
|
||||
<div
|
||||
className={
|
||||
|
||||
@@ -151,6 +151,33 @@ describe("activity grouping", () => {
|
||||
expect(ended.rows.map((r) => r.id)).toEqual(["tool:1", "tool:2", "thought:1", "thought:2"]);
|
||||
});
|
||||
|
||||
it("keeps the newest agent_text outside while the turn is still live", () => {
|
||||
const rows = [
|
||||
text("user:1", "user", "go"),
|
||||
tool("tool:1"),
|
||||
text("agent:1", "agent_text", "working on it"),
|
||||
tool("tool:2"),
|
||||
text("agent:2", "agent_text", "almost done"),
|
||||
];
|
||||
const live = activityGroupsByTurn(rows, null, true).get("turn:user:1")!;
|
||||
// "almost done" stays outside as visible narration while the turn is live
|
||||
expect(live.rows.map((r) => r.id)).toEqual(["tool:1", "agent:1", "tool:2"]);
|
||||
const ended = activityGroupsByTurn(rows, null, false).get("turn:user:1")!;
|
||||
// Once ended, the final agent_text stays outside as the answer
|
||||
expect(ended.rows.map((r) => r.id)).toEqual(["tool:1", "agent:1", "tool:2"]);
|
||||
});
|
||||
|
||||
it("tracks the last tool's label and argument for the collapsed header", () => {
|
||||
const rows = [
|
||||
text("user:1", "user", "go"),
|
||||
row("tool:1", "tool_call", { title: "Read(src/main.ts)", status: "completed" }),
|
||||
row("tool:2", "tool_call", { title: "Execute(git status)", kind: "execute", status: "completed" }),
|
||||
];
|
||||
const group = activityGroupsByTurn(rows, null, false).get("turn:user:1")!;
|
||||
expect(group.lastToolLabel).toBe("Execute");
|
||||
expect(group.lastToolArg).toBe("git status");
|
||||
});
|
||||
|
||||
it("absorbs a resolved approval, never the pending one", () => {
|
||||
const rows = [
|
||||
text("user:1", "user", "go"),
|
||||
|
||||
@@ -148,6 +148,9 @@ export interface ActivityGroup {
|
||||
toolCount: number;
|
||||
/** Tools plus absorbed intermediate text/approvals, in transcript order. */
|
||||
rows: TranscriptRow[];
|
||||
/** `splitToolTitle` of the last tool row — the collapsed header shows it. */
|
||||
lastToolLabel: string;
|
||||
lastToolArg: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -158,6 +161,10 @@ export interface ActivityGroup {
|
||||
* the currently pending one) are absorbed the same way. The *last* agent text
|
||||
* with no subsequent tool stays standalone (the prominent final answer).
|
||||
*
|
||||
* While a turn is still live, the most recent text or thought row trailing the
|
||||
* group also stays outside as visible narration — the operator always sees the
|
||||
* agent's latest reasoning without opening the group.
|
||||
*
|
||||
* Returns groups keyed by `turn:<userId>`.
|
||||
*/
|
||||
export function activityGroupsByTurn(
|
||||
@@ -197,23 +204,36 @@ export function activityGroupsByTurn(
|
||||
continue;
|
||||
}
|
||||
if (row.kind === "tool_call" || row.kind === "tool_diff") {
|
||||
const group = groups.get(currentGid) ?? { toolCount: count, rows: [] };
|
||||
const group = groups.get(currentGid) ?? { toolCount: count, rows: [], lastToolLabel: "", lastToolArg: "" };
|
||||
// Flush any pending rows — a tool follows, so they're intermediate.
|
||||
group.rows.push(...pending);
|
||||
pending = [];
|
||||
group.rows.push(row);
|
||||
const tool = (row.data ?? {}) as { title?: string; name?: string | null; kind?: string };
|
||||
const { label, arg } = splitToolTitle(tool.title, tool.name ?? undefined, tool.kind);
|
||||
group.lastToolLabel = label;
|
||||
group.lastToolArg = arg;
|
||||
groups.set(currentGid, group);
|
||||
}
|
||||
}
|
||||
// A final agent text is the answer, so it stays outside. Thought rows are
|
||||
// working commentary, though: when a turn already has a disclosure they
|
||||
// belong with its activity even if the next tool call has not arrived yet.
|
||||
// While the turn is still live the most recent one stays outside as visible
|
||||
// narration (docs/STRUCTURED-VIEW.md), and is absorbed when the turn ends.
|
||||
// Trailing text/thought rows after the last tool in this turn.
|
||||
// While the turn is still live, the newest one stays outside as visible
|
||||
// narration — the operator always sees the agent's latest reasoning.
|
||||
// A final agent_text (the "answer") stays outside regardless.
|
||||
const lastGroup = groups.get(currentGid);
|
||||
if (lastGroup && pending.length > 0 && pending.every((row) => row.kind === "agent_thought")) {
|
||||
const absorb = turnActive ? pending.slice(0, -1) : pending;
|
||||
lastGroup.rows.push(...absorb);
|
||||
if (lastGroup && pending.length > 0) {
|
||||
const allThoughts = pending.every((row) => row.kind === "agent_thought");
|
||||
if (allThoughts) {
|
||||
// Pure thoughts: absorb all when done, keep the newest outside while live.
|
||||
const absorb = turnActive ? pending.slice(0, -1) : pending;
|
||||
lastGroup.rows.push(...absorb);
|
||||
} else if (turnActive) {
|
||||
// Mixed or agent_text trailing: keep the very last row outside while live,
|
||||
// absorb the rest.
|
||||
lastGroup.rows.push(...pending.slice(0, -1));
|
||||
}
|
||||
// When the turn ends with agent_text last, nothing extra is absorbed — the
|
||||
// text stays outside as the final answer.
|
||||
}
|
||||
|
||||
return groups;
|
||||
|
||||
@@ -1093,7 +1093,7 @@
|
||||
position: relative;
|
||||
width: 40px;
|
||||
padding: 0;
|
||||
border-color: color-mix(in oklab, var(--danger) 35%, var(--border));
|
||||
border-color: transparent;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
@@ -1287,6 +1287,7 @@
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.structured-attach:hover {
|
||||
|
||||
Reference in New Issue
Block a user