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
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ export function FilterBar({
<Input
value={localSearch}
onChange={(e) => setLocalSearch(e.target.value)}
placeholder="search sessions..."
placeholder="search sessions, summaries..."
// pr-7 reserves space for the inline clear button so the
// text never sits under the icon.
className="h-8 w-64 rounded-9 border-none bg-card pr-7 pl-8 font-mono text-[13px] text-ink shadow-xs placeholder:text-ink-3 focus-visible:ring-0"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,11 @@ export function TaskHeader({ detail }: TaskHeaderProps) {
<h1 className="font-extrabold text-2xl tracking-tight">
{task.name}
</h1>
{task.taskSummary && (
<p className="max-w-2xl text-[13px] text-ink-2 leading-snug">
{task.taskSummary}
</p>
)}
</div>
</div>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,23 @@ describe('Audit screen', () => {
// (LIVE / FAILED / STOPPED) render a chip in the agent cell.
})

it('renders the task summary snippet under the target when present', () => {
dataOverride = {
...baseData,
tasks: [
{
...sampleTask,
taskSummary:
'Compared two invoicing tools and noted their pricing tiers.',
},
],
}
const html = renderApp()
expect(html).toContain(
'Compared two invoicing tools and noted their pricing tiers.',
)
})

it('hides token usage from the task list', () => {
dataOverride = {
...baseData,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,16 @@ export const TASK_COLUMNS: ColumnDef<TaskSummary>[] = [
// the one cell a reader scans rather than skims, and the design calls
// for the platform UI face at reading size here.
cell: ({ row }) => (
<span className="block truncate font-[system-ui,sans-serif] text-[13px] text-ledger-ink">
{row.original.name}
</span>
<div className="min-w-0">
<span className="block truncate font-[system-ui,sans-serif] text-[13px] text-ledger-ink">
{row.original.name}
</span>
{row.original.taskSummary && (
<span className="mt-0.5 line-clamp-2 block text-[11px] text-ledger-ink-2 leading-snug">
{row.original.taskSummary}
</span>
)}
</div>
),
enableSorting: false,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,23 @@ describe('TaskDetailPage', () => {
expect(html).not.toContain('/audit/screenshot/2')
})

it('renders the task summary in the header when present', () => {
dataOverride = {
...baseData,
detail: {
...sampleTask,
session: {
...sampleTask.session,
taskSummary: 'Checked warranty terms across three retailer pages.',
},
},
}
const html = render()
expect(html).toContain(
'Checked warranty terms across three retailer pages.',
)
})

it('renders the total token consumption on the summary card', () => {
dataOverride = { ...baseData, detail: sampleTask }
const html = render()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,11 @@ export function TaskDetailPage() {
items={items}
defaultId={pickDefaultTabId(groups)}
listVariant="line"
// Many-tab sessions (one tab per browser page) overflow the fixed
// page width; scroll the strip horizontally instead of spilling
// off-screen, keep each trigger at its natural width, and hide the
// scrollbar so the strip scrolls cleanly with no visible track.
listClassName="w-full max-w-full justify-start overflow-x-auto [&_button]:shrink-0 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden"
/>
<ScreenshotLightbox
sessionId={sessionId}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
//!
//! Producers can select one of these opaque definitions, but cannot construct a
//! new wire event or widen its property schema. Free-form input is reduced to
//! fixed tokens here before the delivery service ever sees it.
//! fixed tokens here before the delivery service ever sees it. The sole
//! exception is the agent-declared `task_summary`, a free-text property that is
//! PII-scrubbed and length-capped upstream and only bounded defensively here.

use serde_json::{Map, Value};
use std::{
Expand All @@ -12,6 +14,7 @@ use std::{

const CLIENT_NAME: &str = "client_name";
const TASK_CATEGORY: &str = "task_category";
const TASK_SUMMARY: &str = "task_summary";
const HARNESS: &str = "harness";
const KIND: &str = "kind";
const TOOL_NAME: &str = "tool_name";
Expand All @@ -34,6 +37,11 @@ const SCREENSHOT_TOKENS_PER_DISPATCH: &str = "screenshot_tokens_per_dispatch";

pub(crate) const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991;

/// Hard cap on the free-text task summary before it leaves the machine. The
/// summary is already PII-scrubbed and length-capped upstream; this is the
/// final defensive bound at the analytics boundary.
pub(crate) const TASK_SUMMARY_MAX_CHARS: usize = 200;

const KNOWN_CLIENTS: [&str; 15] = [
"claude-desktop",
"claude-code",
Expand Down Expand Up @@ -95,6 +103,7 @@ pub(crate) const TASK_CATEGORY_VALUES: [&str; 11] = [
enum PropertyKind {
ClientName,
TaskCategory,
TaskSummary,
Harness,
EndKind,
ToolName,
Expand All @@ -106,11 +115,26 @@ enum PropertyKind {
struct PropertyDefinition {
name: &'static str,
kind: PropertyKind,
/// When true, the event still validates and sends if this property is
/// absent; when present it must still normalize.
optional: bool,
}

impl PropertyDefinition {
const fn new(name: &'static str, kind: PropertyKind) -> Self {
Self { name, kind }
Self {
name,
kind,
optional: false,
}
}

const fn optional(name: &'static str, kind: PropertyKind) -> Self {
Self {
name,
kind,
optional: true,
}
}

fn normalize(self, value: &Value) -> Option<Value> {
Expand All @@ -125,6 +149,15 @@ impl PropertyDefinition {
};
Some(Value::String(category.to_string()))
}
PropertyKind::TaskSummary => {
// The one free-text property: the agent-authored summary is already
// PII-scrubbed and length-capped upstream; here it is only bounded
// defensively and passed through as-is.
let raw = value.as_str()?;
Some(Value::String(
raw.chars().take(TASK_SUMMARY_MAX_CHARS).collect(),
))
}
PropertyKind::Harness => normalize_token(value, &HARNESS_VALUES),
PropertyKind::EndKind => normalize_token(value, &END_KIND_VALUES),
PropertyKind::ToolName => {
Expand Down Expand Up @@ -193,8 +226,19 @@ impl EventDefinition {
let input = properties.as_object()?;
let mut output = Map::new();
for property in self.properties {
let value = input.get(property.name)?;
output.insert(property.name.to_string(), property.normalize(value)?);
let Some(value) = input.get(property.name) else {
if property.optional {
continue;
}
return None;
};
let Some(normalized) = property.normalize(value) else {
if property.optional {
continue;
}
return None;
};
output.insert(property.name.to_string(), normalized);
}
Some(Value::Object(output))
}
Expand All @@ -204,10 +248,11 @@ impl EventDefinition {
properties: &HashMap<String, Value>,
) -> bool {
self.properties.iter().all(|property| {
let Some(current) = properties.get(property.name) else {
return false;
};
property.normalize(current).as_ref() == Some(current)
match properties.get(property.name) {
Some(current) => property.normalize(current).as_ref() == Some(current),
// Absent is acceptable only for optional properties.
None => property.optional,
}
})
}

Expand All @@ -229,6 +274,7 @@ pub const AGENT_SESSION_TASK_DECLARED: EventDefinition = EventDefinition::new(
&[
PropertyDefinition::new(TASK_CATEGORY, PropertyKind::TaskCategory),
PropertyDefinition::new(CLIENT_NAME, PropertyKind::ClientName),
PropertyDefinition::optional(TASK_SUMMARY, PropertyKind::TaskSummary),
],
);
pub const AGENT_SESSION_ENDED: EventDefinition = EventDefinition::new(
Expand Down Expand Up @@ -375,7 +421,7 @@ mod tests {
);
assert_eq!(
AGENT_SESSION_TASK_DECLARED.property_names(),
vec!["task_category", "client_name"]
vec!["task_category", "client_name", "task_summary"]
);
assert_eq!(
AGENT_SESSION_ENDED.property_names(),
Expand Down Expand Up @@ -521,6 +567,47 @@ mod tests {
);
}

#[test]
fn task_declared_passes_through_the_free_text_summary() {
assert_eq!(
AGENT_SESSION_TASK_DECLARED.sanitize(&json!({
"task_category": "shopping",
"client_name": "cursor",
"task_summary": "Compared warranty terms across three retailers.",
})),
Some(json!({
"task_category": "shopping",
"client_name": "cursor",
"task_summary": "Compared warranty terms across three retailers.",
}))
);
}

#[test]
fn task_declared_still_sends_when_the_optional_summary_is_absent() {
assert_eq!(
AGENT_SESSION_TASK_DECLARED
.sanitize(&json!({ "task_category": "research", "client_name": "codex" })),
Some(json!({ "task_category": "research", "client_name": "codex" }))
);
}

#[test]
fn task_summary_is_capped_at_the_boundary() {
let long = "x".repeat(TASK_SUMMARY_MAX_CHARS + 50);
let sanitized = AGENT_SESSION_TASK_DECLARED.sanitize(&json!({
"task_category": "other",
"client_name": "codex",
"task_summary": long,
}));
let summary = sanitized
.as_ref()
.and_then(|value| value.get("task_summary"))
.and_then(Value::as_str)
.unwrap_or_default();
assert_eq!(summary.chars().count(), TASK_SUMMARY_MAX_CHARS);
}

#[test]
fn unlisted_client_names_surface_their_slug() {
for (raw, expected) in [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ async fn contract_summary(task: TaskSummary, live: Option<&Arc<Session>>) -> Ses
.and_then(|session| session.agent().profile_id())
.map(|profile_id| profile_id.as_str().to_string());
summary.site = task.site;
summary.task_summary = task.task_summary;
summary.ended_at = task.ended_at;
summary.latest_screenshot_id = task.last_screenshot_dispatch_id;
summary.token_usage = token_usage;
Expand Down Expand Up @@ -249,6 +250,7 @@ fn contract_live_projection(projection: LiveSessionProjection) -> SessionSummary
summary.harness = harness;
summary.color = Some(color);
summary.site = task.site;
summary.task_summary = task.task_summary;
summary.ended_at = task.ended_at;
summary.latest_screenshot_id = task.last_screenshot_dispatch_id;
summary.token_usage = token_usage;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@ Shared with other agents:
tabs action="new" and work on that copy; leave the original untouched.
- Preserve useful pages: leave anything the user may want to inspect open
instead of closing it when the task ends.
- Name your session early with name_session: a 2-3 word task label plus the
category that best fits the task; tabs group as <client>/<name>.
- Name your session early with name_session: a 2-3 word task label, the category
that best fits the task, and a short PII-free summary you can search for later;
tabs group as <client>/<name>.
- The user oversees this browser from the BrowserOS neo cockpit (live view,
audit, replay).

Expand Down
Loading
Loading