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
7 changes: 7 additions & 0 deletions .changeset/fresh-maps-plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@sapiom/harness": minor
---

Open Studio Agent Maps as a dedicated planning workspace with a live, project-scoped planning conversation beside the durable map. Planner tabs can resume, start fresh, rename, and end; transcript updates refetch through content-free invalidations, map and planner failures retry independently, and mobile keeps the conversation primary behind an explicit Agent Map sheet.

This release adds variants to the public `BusMessage`, `UiEventName`, and `AnalyticsEventType` unions. Consumers that switch over these forward-extensible event types should retain a default arm so later additive events remain source-compatible.
37 changes: 37 additions & 0 deletions packages/harness/src/core/session-record-invalidation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import type { AnalyticsEvent } from "../shared/types.js";

import { sessionRecordChangedMessage } from "./session-record-invalidation.js";

function event(type: AnalyticsEvent["type"]): AnalyticsEvent {
return {
eventId: `event-${type}`,
type,
ts: "2026-09-01T00:00:00.000Z",
agentSessionId: "agent-session-1",
harnessSessionId: "harness-session-1",
harness: "claude-code",
userId: null,
tenantId: null,
machineId: "machine-1",
seq: 1,
payload: {},
};
}

describe("sessionRecordChangedMessage", () => {
it.each(["prompt.submitted", "turn.completed"] as const)(
"invalidates after %s without projecting content",
(type) => {
expect(sessionRecordChangedMessage(event(type))).toEqual({
type: "session.record.changed",
harnessSessionId: "harness-session-1",
});
},
);

it("ignores events that do not change the conversation projection", () => {
expect(sessionRecordChangedMessage(event("tool.call"))).toBeNull();
expect(sessionRecordChangedMessage(event("session.end"))).toBeNull();
});
});
18 changes: 18 additions & 0 deletions packages/harness/src/core/session-record-invalidation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { AnalyticsEvent, BusMessage } from "../shared/types.js";

type SessionRecordChangedMessage = Extract<
BusMessage,
{ type: "session.record.changed" }
>;

/** Content-free invalidation for events that change the visible transcript. */
export function sessionRecordChangedMessage(
event: AnalyticsEvent,
): SessionRecordChangedMessage | null {
return event.type === "prompt.submitted" || event.type === "turn.completed"
? {
type: "session.record.changed",
harnessSessionId: event.harnessSessionId,
}
: null;
}
82 changes: 79 additions & 3 deletions packages/harness/src/server/agent-map.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -472,12 +472,38 @@ describe("createAgentMapRouter", () => {
greeting: { status: "pending" as const },
queuedInputIds: ["input-1"],
}));
const retry = vi.fn(async () => {});
const retry = vi.fn(async () => {
if (!plannerSession.planning) throw new Error("missing planner metadata");
plannerSession.planning = {
...plannerSession.planning,
greeting: { status: "generating", attemptId: "attempt-2" },
};
});
const fixture = await start({
planningSessions: { open, requireOwned } as unknown as PlanningSessionService,
plannerGreeting: { enqueue, retry } as unknown as PlannerGreetingCoordinator,
planningSessions: {
open,
requireOwned,
} as unknown as PlanningSessionService,
plannerGreeting: {
enqueue,
retry,
} as unknown as PlannerGreetingCoordinator,
});
fixtureProjectId = fixture.project.projectId;
plannerSession.planning = {
identity: {
projectId: fixtureProjectId,
sessionId: plannerSession.id,
userId: "user-1",
role: "map-planner",
},
greeting: {
status: "failed",
retryable: true,
errorCode: "model_turn_failed",
},
queuedInputIds: [],
};
const route = `${fixture.baseUrl}/api/projects/${fixture.project.projectId}/planner-sessions`;

expect(
Expand Down Expand Up @@ -517,6 +543,56 @@ describe("createAgentMapRouter", () => {
mode: "fresh",
harness: "codex",
});

const message = await fetch(`${route}/${plannerSession.id}/messages`, {
method: "POST",
headers: {
"content-type": "application/json",
"X-Harness-Token": "test-token",
},
body: JSON.stringify({ text: "Build a support triage system" }),
});
expect(message.status).toBe(202);
expect(await message.json()).toEqual({
metadata: {
identity: {
projectId: fixture.project.projectId,
sessionId: plannerSession.id,
userId: "user-1",
role: "map-planner",
},
greeting: { status: "pending" },
queuedInputIds: ["input-1"],
},
});
expect(requireOwned).toHaveBeenCalledWith(
fixture.project.projectId,
plannerSession.id,
);
expect(enqueue).toHaveBeenCalledWith(
plannerSession.id,
"Build a support triage system",
);

const retryResponse = await fetch(
`${route}/${plannerSession.id}/greeting/retry`,
{
method: "POST",
headers: {
"content-type": "application/json",
"X-Harness-Token": "test-token",
},
body: "{}",
},
);
expect(retryResponse.status).toBe(202);
expect(await retryResponse.json()).toEqual({
metadata: {
...plannerSession.planning,
greeting: { status: "generating", attemptId: "attempt-2" },
},
});
expect(retry).toHaveBeenCalledWith(plannerSession.id);
});

it("rejects foreign planner messages and bounds unavailable retries", async () => {
Expand Down
3 changes: 3 additions & 0 deletions packages/harness/src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import {
createClaudeTranscriptEnricher,
createSessionRecordReader,
} from "../core/session-record.js";
import { sessionRecordChangedMessage } from "../core/session-record-invalidation.js";
import {
buildRehydrationBrief,
systemPromptDeliveryFor,
Expand Down Expand Up @@ -3236,6 +3237,8 @@ export const startServer = async (
void plannerGreeting.onEventPersisted(event).catch((error: unknown) => {
console.error("[harness] planner greeting completion failed:", error);
});
const recordChanged = sessionRecordChangedMessage(event);
if (recordChanged) bus.publish(recordChanged);
// The normal end of a session: the SessionEnd hook's event is in the
// store, so the archived record carries the whole conversation including
// its `endedAt`. (The "exited" status handler archives too, for sessions
Expand Down
37 changes: 37 additions & 0 deletions packages/harness/src/server/ingest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,43 @@ describe("createIngestRouter", () => {
expect(enqueued).toHaveLength(1);
});

it("notifies transcript consumers only after the event is durably appended", async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
const appendEntered = deferred();
const appendCommit = deferred();
const order: string[] = [];
start({
store: {
append: async (event) => {
order.push(`append:${event.type}`);
appendEntered.resolve();
await appendCommit.promise;
stored.push(event);
order.push(`persisted:${event.type}`);
},
},
onEventPersisted: (event) => order.push(`notify:${event.type}`),
});

const res = await postIngest(baseUrl, {
hookEvent: "UserPromptSubmit",
harnessSessionId: "session-1",
payload: { session_id: "agent-1", prompt: "hello" },
});
expect(res.status).toBe(200);
await appendEntered.promise;
expect(order).toEqual(["append:prompt.submitted"]);

appendCommit.resolve();
await vi.waitFor(() =>
expect(order).toEqual([
"append:prompt.submitted",
"persisted:prompt.submitted",
"notify:prompt.submitted",
]),
);
});

it("assigns a monotonically increasing seq per harnessSessionId, server-side", async () => {
await postIngest(baseUrl, {
hookEvent: "UserPromptSubmit",
Expand Down
2 changes: 2 additions & 0 deletions packages/harness/src/server/rest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,8 @@ const UI_EVENT_NAMES: readonly UiEventName[] = [
"session.created",
"mcp.install",
"plan.upgrade_clicked",
"agent_map.entered",
"agent_map.workspace_load_failed",
];

/**
Expand Down
4 changes: 4 additions & 0 deletions packages/harness/src/server/track.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,10 @@ describe("POST /api/track", () => {
"visualize.triggered",
"consent.changed",
"session.created",
"mcp.install",
"plan.upgrade_clicked",
"agent_map.entered",
"agent_map.workspace_load_failed",
] as const;

for (const event of events) {
Expand Down
5 changes: 5 additions & 0 deletions packages/harness/src/shared/agent-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,11 @@ export interface PlannerMessageRequest {
text: string;
}

/** Authoritative coordinator state returned after a planner mutation. */
export interface PlannerSessionMetadataResponse {
metadata: PlannerSessionMetadata;
}

/**
* Content-free planner lifecycle telemetry. Callers may persist these fields,
* but must never add prompts, assistant text, local paths, or provider errors.
Expand Down
12 changes: 11 additions & 1 deletion packages/harness/src/shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -522,6 +522,12 @@ export type TerminalControlMessage = TerminalResizeMessage;

export type BusMessage =
| { type: "session.status"; session: HarnessSession }
/**
* A prompt or completed turn is now durable in the local event store.
* Consumers refetch the existing SessionRecord snapshot; conversation
* content never rides the event bus.
*/
| { type: "session.record.changed"; harnessSessionId: string }
| { type: "canvas.reload"; harnessSessionId: string }
| {
type: "port.detected";
Expand Down Expand Up @@ -763,7 +769,9 @@ export type UiEventName =
| "consent.changed"
| "session.created"
| "mcp.install"
| "plan.upgrade_clicked";
| "plan.upgrade_clicked"
| "agent_map.entered"
| "agent_map.workspace_load_failed";

export interface UiTrackRequest {
/** Dot-canonical event name — one of the UiEventName literals. */
Expand Down Expand Up @@ -793,6 +801,8 @@ export type AnalyticsEventType =
| "session.created"
| "mcp.install"
| "plan.upgrade_clicked"
| "agent_map.entered"
| "agent_map.workspace_load_failed"
| "agent_map.workspace_initialized"
| "agent_map.workspace_read_failed"
| "planner_session.created"
Expand Down
Loading
Loading