Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/agent-download-applet.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@agent-native/core": patch
---

The agent now hands over files it creates instead of describing where to find them. A new `offer-download` action resolves a workspace file to an access-scoped download URL and renders it as a compact download card in chat, the resources route supports `?download` for a real save-to-disk response, and a framework rule tells the agent to hand over artifacts rather than give app-navigation directions.
6 changes: 6 additions & 0 deletions .changeset/chat-queue-and-model-picker.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@agent-native/core": patch
"@agent-native/toolkit": patch
---

Queued chat messages are now manageable: each shows a Queued chip and its position, and can be edited in place, reordered, removed, or sent immediately (interrupting the active run). A turn that ended in an error keeps a compact error marker in the transcript instead of losing it as soon as the next message is sent, and the model picker states when a family is listed cheapest first and notes an in-session model switch in the transcript.
5 changes: 5 additions & 0 deletions .changeset/chat-transcript-work-accuracy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@agent-native/core": patch
---

Chat transcript now reports work accurately. Each work group in a turn shows its own duration instead of every group repeating the whole-turn time, a `tool_done` that matches no in-flight card settles that card rather than being dropped (which left tools spinning "Still working" for the rest of the turn), and a tool that never reported back renders as an unknown outcome instead of a spinner or a clean success.
11 changes: 11 additions & 0 deletions .changeset/context-meter-freshness.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@agent-native/core": patch
"@agent-native/toolkit": patch
---

Context meter now reports its own freshness. `context-manifest-get` returns the
thread's newest turn id, `writeContextManifest` returns a typed persist outcome
instead of a swallowed rejection, and the meter dims with an explanation when
the manifest trails the running turn or shows an em dash when no usable reading
exists. The meter also refetches while a run is streaming, which is what kept it
frozen on the first turn's percentage.
2 changes: 2 additions & 0 deletions packages/core/src/action-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ export const ACTION_CHAT_UI_DATA_CHART_RENDERER = "core.data-chart";
export const ACTION_CHAT_UI_DATA_INSIGHTS_RENDERER = "core.data-insights";
export const ACTION_CHAT_UI_DATA_WIDGET_RENDERER = "core.data-widget";
export const ACTION_CHAT_UI_INLINE_EXTENSION_RENDERER = "core.inline-extension";
export const ACTION_CHAT_UI_DOWNLOAD_ARTIFACT_RENDERER =
"core.download-artifact";

export interface ActionChatUIConfig {
/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ import {
type ContextManifest,
} from "../../../shared/context-xray.js";
import { callerOwnsThread } from "../../run-ownership.js";
import { getRunByThread } from "../../run-store.js";
import { readContextManifest } from "../directives-store.js";
import { getContextManifestWriteOutcome } from "../manifest.js";
import {
contextXrayAuthError,
contextXrayThreadNotFoundError,
Expand Down Expand Up @@ -62,14 +64,29 @@ export default defineAction({
if (!ownerEmail) throw contextXrayAuthError();
const ownsThread = await callerOwnsThread(ownerEmail, args.threadId);
if (!ownsThread) throw contextXrayThreadNotFoundError();
const stored = await readContextManifest(args.threadId);
const manifest =
(await readContextManifest(args.threadId)) ??
emptyContextManifest(args.threadId, { enforceable: true });
stored ?? emptyContextManifest(args.threadId, { enforceable: true });
const latestRun = await getRunByThread(args.threadId, {
includeTerminal: true,
});
const latestTurnId = latestRun
? (latestRun.turnId ?? latestRun.id)
: undefined;
// A persist failure recorded after the stored manifest was written means
// the stored manifest describes an older turn than the one that just ran.
const outcome = getContextManifestWriteOutcome(args.threadId);
const writeFailedAfterStored =
outcome?.status === "failed" &&
outcome.failedAt > (stored?.updatedAt ?? 0);
Comment on lines +79 to +81

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Do not let an older async write failure invalidate a newer manifest

Manifest writes are intentionally fire-and-forget, so an older iteration can fail after a later iteration has successfully persisted. That older failure becomes the latest process-local outcome and is then surfaced against the newer stored manifest, causing the meter to show unavailable incorrectly. Track write generation/ownership and only surface a failure when it applies to the stored manifest.

Additional Info
Found by 1 of 3 code-review agents; consecutive transform iterations can overlap writes and all share the same logical turnId.

Fix in Builder

Comment on lines +79 to +81

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Do not mark an empty manifest as a failed persisted reading

When there is no stored manifest, (stored?.updatedAt ?? 0) makes any in-memory failure outcome satisfy this condition. The action then adds writeStatus: "failed" to the synthetic empty manifest, conflating “no manifest has ever been written” with “an older persisted reading was invalidated.” Only apply this failed-write marker when a stored manifest exists.

Additional Info
New finding from 1 of 3 incremental review agents; verified against the empty-manifest branch.

Fix in Builder

return {
...manifest,
url: contextXrayDeepLink(args.threadId),
enforceable: manifest.enforceable ?? true,
source: manifest.source ?? "structured",
...(latestTurnId ? { latestTurnId } : {}),
...(latestRun ? { latestTurnStartedAt: latestRun.startedAt } : {}),
...(writeFailedAfterStored ? { writeStatus: "failed" as const } : {}),
};
},
});
45 changes: 45 additions & 0 deletions packages/core/src/agent/context-xray/manifest-write.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { describe, expect, it, vi } from "vitest";

import {
_resetContextManifestWriteOutcomes,
buildManifest,
getContextManifestWriteOutcome,
writeContextManifest,
} from "./manifest.js";

const appStatePut = vi.hoisted(() => vi.fn(async () => {}));

vi.mock("../../application-state/store.js", () => ({ appStatePut }));

describe("writeContextManifest outcomes", () => {
it("reports a failed persist instead of looking like a completed one", async () => {
_resetContextManifestWriteOutcomes();
appStatePut.mockRejectedValueOnce(new Error("store unavailable"));
const manifest = await buildManifest({
threadId: "thread-write",
turnId: "turn-2",
rawMessages: [],
sentMessages: [],
appliedStatus: new Map(),
directives: new Map(),
});

const failed = await writeContextManifest("thread-write", manifest);
expect(failed).toMatchObject({
status: "failed",
turnId: "turn-2",
error: "store unavailable",
});
expect(getContextManifestWriteOutcome("thread-write")).toBe(failed);

const written = await writeContextManifest("thread-write", manifest);
expect(written.status).toBe("written");
expect(appStatePut).toHaveBeenLastCalledWith(
"thread-write",
expect.any(String),
expect.objectContaining({ writeStatus: "written", turnId: "turn-2" }),
expect.objectContaining({ requestSource: "context-xray" }),
);
_resetContextManifestWriteOutcomes();
});
});
101 changes: 92 additions & 9 deletions packages/core/src/agent/context-xray/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,18 +239,101 @@ export async function buildManifest(
};
}

export type ContextManifestWriteOutcome =
| { status: "written"; turnId?: string; updatedAt: number }
| { status: "failed"; turnId?: string; failedAt: number; error: string };

/**
* Last persist outcome per thread. A failed write leaves the previous turn's
* manifest in application state, which reads back as a perfectly plausible
* manifest — this map is the only thing that can tell a reader the newest
* turn never made it to storage.
*/
const writeOutcomes = new Map<string, ContextManifestWriteOutcome>();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Persist manifest write failures outside the process-local outcome map

The failure outcome is retained only in an in-memory map, while the manifest and run lookup are durable. If the write runs in a different worker, or the process restarts before context-manifest-get, the failed latest turn is indistinguishable from an older manifest and the UI can show a stale percentage instead of unavailable. Persist a bounded failure marker or derive the failure state from durable data, clearing/replacing it on success.

Additional Info
Found by 1 of 3 code-review agents; deployment behavior makes process-local outcome tracking insufficient for the stated freshness guarantee.

Fix in Builder

const MAX_TRACKED_WRITE_OUTCOMES = 500;

function rememberWriteOutcome(
threadId: string,
outcome: ContextManifestWriteOutcome,
): void {
writeOutcomes.delete(threadId);
writeOutcomes.set(threadId, outcome);
while (writeOutcomes.size > MAX_TRACKED_WRITE_OUTCOMES) {
const oldest = writeOutcomes.keys().next();
if (oldest.done) break;
writeOutcomes.delete(oldest.value);
}
}

export function getContextManifestWriteOutcome(
threadId: string,
): ContextManifestWriteOutcome | undefined {
return writeOutcomes.get(threadId);
}

/**
* Record that no manifest could be produced for a turn (the transform threw
* before it ever reached `writeContextManifest`).
*/
export function recordContextManifestWriteFailure(
threadId: string,
error: unknown,
turnId?: string,
): void {
rememberWriteOutcome(threadId, {
status: "failed",
...(turnId ? { turnId } : {}),
failedAt: Date.now(),
error: error instanceof Error ? error.message : String(error),
});
}

/** @internal test helper */
export function _resetContextManifestWriteOutcomes(): void {
writeOutcomes.clear();
}

/**
* Persist a manifest and report the outcome. Returns a typed failure instead
* of throwing so the fire-and-forget caller cannot drop it silently, and so
* `context-manifest-get` can tell readers the meter is showing an older turn.
*/
export async function writeContextManifest(
threadId: string,
manifest: ContextManifest,
): Promise<void> {
await appStatePut(
threadId,
CONTEXT_XRAY_MANIFEST_KEY,
manifest as unknown as Record<string, unknown>,
{
requestSource: "context-xray",
},
);
): Promise<ContextManifestWriteOutcome> {
const updatedAt = Date.now();
const persisted: ContextManifest = {
...manifest,
updatedAt,
writeStatus: "written",
};
try {
await appStatePut(
threadId,
CONTEXT_XRAY_MANIFEST_KEY,
persisted as unknown as Record<string, unknown>,
{
requestSource: "context-xray",
},
);
} catch (err) {
const outcome: ContextManifestWriteOutcome = {
status: "failed",
...(manifest.turnId ? { turnId: manifest.turnId } : {}),
failedAt: Date.now(),
error: err instanceof Error ? err.message : String(err),
};
rememberWriteOutcome(threadId, outcome);
return outcome;
}
const outcome: ContextManifestWriteOutcome = {
status: "written",
...(manifest.turnId ? { turnId: manifest.turnId } : {}),
updatedAt,
};
rememberWriteOutcome(threadId, outcome);
return outcome;
}

export function updateManifestSegmentStatus(
Expand Down
16 changes: 11 additions & 5 deletions packages/core/src/agent/engine/context-directives-transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { applyContextDirectives } from "../context-xray/apply-directives.js";
import { loadContextDirectives } from "../context-xray/directives-store.js";
import {
buildManifest,
recordContextManifestWriteFailure,
writeContextManifest,
} from "../context-xray/manifest.js";
import { computeProtectedSegmentIds } from "../context-xray/segments.js";
Expand All @@ -20,6 +21,11 @@ import type { EngineMessage } from "./types.js";
* break the turn. The manifest write is fire-and-forget (not awaited) so it
* never adds latency to the model-call path.
*
* Both failure paths record the miss through `recordContextManifestWriteFailure`
* / `writeContextManifest`'s typed outcome. Without that record the thread
* keeps its previous turn's manifest, and the context meter renders that
* older percentage as if it described the turn now running.
*
* Moved verbatim out of `runAgentLoop`'s per-iteration setup — behavior
* unchanged. Caller is still responsible for gating this on `threadId` being
* present and for the separate Observational Memory pass that runs after it.
Expand Down Expand Up @@ -56,14 +62,14 @@ export async function applyContextXrayTransformForIteration(opts: {
source: "structured",
enforceable: true,
});
void writeContextManifest(threadId, manifest).catch((err) => {
console.warn(
"[context-xray] failed to write manifest:",
err instanceof Error ? err.message : String(err),
);
void writeContextManifest(threadId, manifest).then((outcome) => {
if (outcome.status === "failed") {
console.warn("[context-xray] failed to write manifest:", outcome.error);
}
});
return transformedMessages;
} catch (err) {
recordContextManifestWriteFailure(threadId, err, turnId);
console.warn(
"[context-xray] context transform skipped:",
err instanceof Error ? err.message : String(err),
Expand Down
6 changes: 5 additions & 1 deletion packages/core/src/agent/run-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1045,7 +1045,11 @@ export function startRun(
const send = (event: AgentChatEvent) => {
if (run.status === "aborted" && abort.signal.aborted) return;

const runEvent: RunEvent = { seq: run.events.length, event };
const runEvent: RunEvent = {
seq: run.events.length,
event,
at: Date.now(),
Comment on lines +1048 to +1051

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Persist event timestamps with the run events

at is added only to the in-memory RunEvent, while emitRunEvent persists JSON.stringify(runEvent.event) and the live SSE payload likewise omits the envelope timestamp. SQL replay and transcript rebuilds therefore still lack per-part timestamps and fall back to whole-turn durations after reload or reconnect. Persist and restore the timestamp through the event storage/SSE path.

Additional Info
New finding from 1 of 3 incremental review agents; verified against the surrounding persistence code.

Fix in Builder

};
if (isTerminalRunEvent(event)) {
pendingTerminalEvent = runEvent;
return;
Expand Down
31 changes: 25 additions & 6 deletions packages/core/src/agent/thread-data-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ interface ContentPart {
completedSideEffect?: boolean;
mcpApp?: AgentMcpAppPayload;
chatUI?: ActionChatUIConfig;
startedAt?: number;
completedAt?: number;
}

interface BuildAssistantMessageOptions {
Expand Down Expand Up @@ -129,16 +131,21 @@ export function buildAssistantMessage(
}
};

const appendReasoning = (text: string) => {
const appendReasoning = (text: string, at?: number) => {
const last = content[content.length - 1];
if (last && last.type === "reasoning") {
last.text = (last.text ?? "") + text;
if (typeof at === "number") last.completedAt = at;
} else {
content.push({ type: "reasoning", text });
content.push({
type: "reasoning",
text,
...(typeof at === "number" ? { startedAt: at, completedAt: at } : {}),
});
}
};

for (const [index, { event }] of events.entries()) {
for (const [index, { event, at: eventAt }] of events.entries()) {
if (event.type === "clear") {
// A live stream always follows `clear` with the chunk that re-emits the
// wiped content. A rebuild has no successor, so applying a TRAILING
Expand All @@ -154,7 +161,7 @@ export function buildAssistantMessage(
}

if (event.type === "thinking") {
appendReasoning(event.text ?? "");
appendReasoning(event.text ?? "", eventAt);
continue;
}

Expand All @@ -170,6 +177,7 @@ export function buildAssistantMessage(
toolName: event.tool ?? "unknown",
argsText: JSON.stringify(args),
args,
...(typeof eventAt === "number" ? { startedAt: eventAt } : {}),
});
continue;
}
Expand Down Expand Up @@ -209,6 +217,7 @@ export function buildAssistantMessage(
const part = content[matchingIndex];
if (part?.type === "tool-call") {
part.result = event.result ?? "";
if (typeof eventAt === "number") part.completedAt = eventAt;
if (event.isError !== undefined) part.isError = event.isError;
if (event.completedSideEffect !== undefined) {
part.completedSideEffect = event.completedSideEffect;
Expand Down Expand Up @@ -411,11 +420,21 @@ function normalizeAttachmentIdentity(attachments: unknown): unknown {
// turn rendered twice. The id never participates in message identity (history
// replay regenerates its own ids), so hashing content without it is the correct
// notion of "same message".
// Work timestamps are stripped for the same reason: the client stamps browser
// clock time as events arrive and the server stamps its own clock as they are
// emitted, so the same turn would otherwise hash two ways and render twice.
function normalizeContentForFingerprint(content: unknown): unknown {
if (!Array.isArray(content)) return content;
return content.map((part: any) =>
part && typeof part === "object" && part.type === "tool-call"
? { ...part, toolCallId: undefined }
part &&
typeof part === "object" &&
(part.type === "tool-call" || part.type === "reasoning")
? {
...part,
...(part.type === "tool-call" ? { toolCallId: undefined } : {}),
startedAt: undefined,
completedAt: undefined,
}
: part,
);
}
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/agent/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,13 @@ export function isContinuationTerminalReason(reason: unknown): boolean {
export interface RunEvent {
seq: number;
event: AgentChatEvent;
/**
* Epoch ms the run emitted this event. Persisted transcripts are rebuilt
* from these events, so without a per-event time the rebuilt tool cards
* carry no duration and a reloaded thread falls back to printing the
* whole-turn duration.
*/
at?: number;
}

/**
Expand Down
Loading
Loading