Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 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
6 changes: 4 additions & 2 deletions apps/mobile/src/features/threads/thread-work-log.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,11 +109,12 @@ export function collapsedWorkLogHeight(
return 0;
}
const onlyToolRows = rows.every((row) => row.toolLike);
const containsReasoning = rows.some((row) => row.reasoning === true);
const headerHeight =
scaledTypographyLineHeight(MOBILE_TYPOGRAPHY.caption, baseFontSize) + WORK_LOG_HEADER_PADDING;
return (
WORK_LOG_BOTTOM_MARGIN +
(onlyToolRows ? 0 : headerHeight) +
(onlyToolRows || containsReasoning ? 0 : headerHeight) +
rows.length * WORK_ROW_HEIGHT +
(rows.length - 1) * WORK_ROW_GAP
);
Expand All @@ -139,10 +140,11 @@ export function ThreadWorkLog(props: {
}

const onlyToolRows = rows.every((row) => row.toolLike);
const containsReasoning = rows.some((row) => row.reasoning === true);

return (
<View className="-mx-1 mb-1 px-1 py-0">
{!onlyToolRows ? (
{!onlyToolRows && !containsReasoning ? (
<Text className="px-0.5 pb-0.5 font-t3-medium text-2xs text-foreground-muted opacity-60">
work log
</Text>
Expand Down
35 changes: 35 additions & 0 deletions apps/mobile/src/lib/threadActivity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,41 @@ function makeThread(
}

describe("buildThreadFeed", () => {
it("renders Claude reasoning as a non-tool thought activity", () => {
const thread = makeThread({
id: ThreadId.make("thread-reasoning"),
projectId: ProjectId.make("project-1"),
title: "Reasoning thread",
activities: [
makeActivity({
id: EventId.make("reasoning:thread-reasoning:item-1"),
kind: "reasoning",
summary: "Thought",
tone: "info",
createdAt: "2026-04-01T00:00:01.000Z",
turnId: TurnId.make("turn-1"),
payload: { detail: "Inspecting the source", streaming: false },
}),
],
});

const group = buildThreadFeed(thread)[0];
expect(group).toMatchObject({
type: "activity-group",
activities: [
{
id: "reasoning:thread-reasoning:item-1",
summary: "Thought",
detail: "Inspecting the source",
icon: "agent",
toolLike: false,
status: null,
reasoning: true,
},
],
});
});

it("keeps historic work entries attributed to their turns", () => {
const thread = makeThread({
id: ThreadId.make("thread-1"),
Expand Down
12 changes: 11 additions & 1 deletion apps/mobile/src/lib/threadActivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export interface ThreadFeedActivity {
| "zap";
readonly toolLike: boolean;
readonly status: "success" | "failure" | "neutral" | null;
readonly reasoning?: boolean;
}

const MAX_VISIBLE_WORK_LOG_ENTRIES = 1;
Expand All @@ -75,6 +76,7 @@ interface WorkLogEntry {
requestKind?: PendingApproval["requestKind"];
toolLifecycleStatus?: WorkLogToolLifecycleStatus;
toolData?: unknown;
reasoning?: boolean;
}

interface DerivedWorkLogEntry extends WorkLogEntry {
Expand Down Expand Up @@ -382,7 +384,7 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
...(taskId ? { taskId } : {}),
label: taskLabel || activity.summary,
tone:
activity.kind === "task.progress"
activity.kind === "task.progress" || activity.kind === "reasoning"
? "thinking"
: activity.tone === "approval"
? "info"
Expand All @@ -402,6 +404,9 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
entry.detail = detail;
}
}
if (activity.kind === "reasoning") {
entry.reasoning = true;
}
if (commandPreview.command) {
entry.command = commandPreview.command;
}
Expand Down Expand Up @@ -548,6 +553,9 @@ function normalizeCompactToolLabel(value: string): string {
}

function workLogEntryIsToolLike(entry: WorkLogEntry): boolean {
if (entry.reasoning === true) {
return false;
}
if (entry.tone === "tool" || entry.tone === "thinking" || entry.tone === "error") {
return true;
}
Expand Down Expand Up @@ -622,6 +630,7 @@ function workEntryStatus(entry: WorkLogEntry): ThreadFeedActivity["status"] {
}

function workEntryIcon(entry: DerivedWorkLogEntry): ThreadFeedActivity["icon"] {
if (entry.activityKind === "reasoning") return "agent";
if (
entry.activityKind === "user-input.requested" ||
entry.activityKind === "user-input.resolved"
Expand Down Expand Up @@ -1566,6 +1575,7 @@ export function buildThreadFeed(
icon: workEntryIcon(entry),
toolLike: workLogEntryIsToolLike(entry),
status: workEntryStatus(entry),
...(entry.reasoning === true ? { reasoning: true } : {}),
},
};
}),
Expand Down
49 changes: 38 additions & 11 deletions apps/server/src/orchestration/Layers/ProjectionPipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
type OrchestrationEvent,
type OrchestrationSessionStatus,
ThreadId,
mergeOrchestrationThreadActivity,
} from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
Expand Down Expand Up @@ -1079,21 +1080,47 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
"applyThreadActivitiesProjection",
)(function* (event, _attachmentSideEffects) {
switch (event.type) {
case "thread.activity-appended":
yield* projectionThreadActivityRepository.upsert({
case "thread.activity-appended": {
const existingActivity = yield* projectionThreadActivityRepository.getByActivityId({
activityId: event.payload.activity.id,
});
const previousActivity = Option.map(existingActivity, (row) => ({
id: row.activityId,
tone: row.tone,
kind: row.kind,
summary: row.summary,
payload: row.payload,
turnId: row.turnId,
...(row.sequence !== undefined ? { sequence: row.sequence } : {}),
createdAt: row.createdAt,
})).pipe(Option.getOrUndefined);
const activity = mergeOrchestrationThreadActivity(
previousActivity,
event.payload.activity,
);
// A reasoning settle with no streamed row and no text of its own is an
// orphan (codex completes reasoning items without a detail string);
// inserting it would render an empty Thought entry.
if (
activity.kind === "reasoning" &&
previousActivity === undefined &&
typeof (activity.payload as { detail?: unknown } | null)?.detail !== "string"
) {
return;
}
Comment thread
cursor[bot] marked this conversation as resolved.
yield* projectionThreadActivityRepository.upsert({
activityId: activity.id,
threadId: event.payload.threadId,
turnId: event.payload.activity.turnId,
tone: event.payload.activity.tone,
kind: event.payload.activity.kind,
summary: event.payload.activity.summary,
payload: event.payload.activity.payload,
...(event.payload.activity.sequence !== undefined
? { sequence: event.payload.activity.sequence }
: {}),
createdAt: event.payload.activity.createdAt,
turnId: activity.turnId,
tone: activity.tone,
kind: activity.kind,
summary: activity.summary,
payload: activity.payload,
...(activity.sequence !== undefined ? { sequence: activity.sequence } : {}),
createdAt: activity.createdAt,
});
return;
}

case "thread.reverted": {
const existingRows = yield* projectionThreadActivityRepository.listByThreadId({
Expand Down
181 changes: 181 additions & 0 deletions apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1025,6 +1025,187 @@ describe("ProviderRuntimeIngestion", () => {
expect(message?.streaming).toBe(false);
});

it("ignores reasoning_summary_text deltas so two streams never share one activity", async () => {
const harness = await createHarness();

harness.emit({
type: "content.delta",
eventId: asEventId("evt-reasoning-summary-delta"),
provider: ProviderDriverKind.make("codex"),
createdAt: "2026-01-01T00:00:00.000Z",
threadId: asThreadId("thread-1"),
turnId: asTurnId("turn-reasoning"),
itemId: asItemId("item-summary-only"),
payload: {
streamKind: "reasoning_summary_text",
delta: "Summary prose",
},
});
harness.emit({
type: "content.delta",
eventId: asEventId("evt-reasoning-raw-delta"),
provider: ProviderDriverKind.make("codex"),
createdAt: "2026-01-01T00:00:01.000Z",
threadId: asThreadId("thread-1"),
turnId: asTurnId("turn-reasoning"),
itemId: asItemId("item-raw"),
payload: {
streamKind: "reasoning_text",
delta: "Raw reasoning",
},
});

// Events project in order: once the raw item's activity exists, the
// summary delta has already been processed and must have produced nothing.
const thread = await waitForThread(harness.readModel, (entry) =>
entry.activities.some(
(activity: ProviderRuntimeTestActivity) => activity.id === "reasoning:thread-1:item-raw",
),
);
expect(
thread.activities.some(
(activity: ProviderRuntimeTestActivity) =>
activity.id === "reasoning:thread-1:item-summary-only",
),
).toBe(false);
});

it("settles a streamed reasoning activity when the completion has no detail", async () => {
const harness = await createHarness();

// An orphan settle — no streamed row, no detail — must not create a row.
// Emitted first so the wait below proves it was processed and dropped.
harness.emit({
type: "item.completed",
eventId: asEventId("evt-reasoning-orphan-completed"),
provider: ProviderDriverKind.make("codex"),
createdAt: "2026-01-01T00:00:00.000Z",
threadId: asThreadId("thread-1"),
turnId: asTurnId("turn-reasoning"),
itemId: asItemId("item-orphan"),
payload: {
itemType: "reasoning",
status: "completed",
},
});
harness.emit({
type: "content.delta",
eventId: asEventId("evt-reasoning-nodetail-delta"),
provider: ProviderDriverKind.make("claudeAgent"),
createdAt: "2026-01-01T00:00:01.000Z",
threadId: asThreadId("thread-1"),
turnId: asTurnId("turn-reasoning"),
itemId: asItemId("item-nodetail"),
payload: {
streamKind: "reasoning_text",
delta: "Weighing options",
},
});
// Completion without a detail string (codex reasoning items usually carry
// none) must still flip the streamed activity out of its live state.
harness.emit({
type: "item.completed",
eventId: asEventId("evt-reasoning-nodetail-completed"),
provider: ProviderDriverKind.make("claudeAgent"),
createdAt: "2026-01-01T00:00:02.000Z",
threadId: asThreadId("thread-1"),
turnId: asTurnId("turn-reasoning"),
itemId: asItemId("item-nodetail"),
payload: {
itemType: "reasoning",
status: "completed",
},
});

const thread = await waitForThread(harness.readModel, (entry) =>
entry.activities.some(
(activity: ProviderRuntimeTestActivity) =>
activity.id === "reasoning:thread-1:item-nodetail" &&
(activity.payload as { streaming?: boolean }).streaming === false,
),
);
expect(
thread.activities.find(
(activity: ProviderRuntimeTestActivity) =>
activity.id === "reasoning:thread-1:item-nodetail",
)?.payload,
).toMatchObject({ detail: "Weighing options", streaming: false });
expect(
thread.activities.some(
(activity: ProviderRuntimeTestActivity) => activity.id === "reasoning:thread-1:item-orphan",
),
).toBe(false);
});

it("merges streamed reasoning into one persisted activity and settles it", async () => {
const harness = await createHarness();

harness.emit({
type: "content.delta",
eventId: asEventId("evt-reasoning-delta-1"),
provider: ProviderDriverKind.make("claudeAgent"),
createdAt: "2026-01-01T00:00:00.000Z",
threadId: asThreadId("thread-1"),
turnId: asTurnId("turn-reasoning"),
itemId: asItemId("item-reasoning"),
payload: {
streamKind: "reasoning_text",
delta: "Checking",
},
});
harness.emit({
type: "content.delta",
eventId: asEventId("evt-reasoning-delta-2"),
provider: ProviderDriverKind.make("claudeAgent"),
createdAt: "2026-01-01T00:00:01.000Z",
threadId: asThreadId("thread-1"),
turnId: asTurnId("turn-reasoning"),
itemId: asItemId("item-reasoning"),
payload: {
streamKind: "reasoning_text",
delta: " the source",
},
});
harness.emit({
type: "item.completed",
eventId: asEventId("evt-reasoning-completed"),
provider: ProviderDriverKind.make("claudeAgent"),
createdAt: "2026-01-01T00:00:02.000Z",
threadId: asThreadId("thread-1"),
turnId: asTurnId("turn-reasoning"),
itemId: asItemId("item-reasoning"),
payload: {
itemType: "reasoning",
status: "completed",
title: "Thought",
detail: "Checking the source.",
},
});

const thread = await waitForThread(harness.readModel, (entry) =>
entry.activities.some(
(activity: ProviderRuntimeTestActivity) =>
activity.id === "reasoning:thread-1:item-reasoning" &&
(activity.payload as { streaming?: boolean }).streaming === false,
),
);
const reasoning = thread.activities.filter(
(activity: ProviderRuntimeTestActivity) => activity.kind === "reasoning",
);

expect(reasoning).toHaveLength(1);
expect(reasoning[0]).toMatchObject({
id: "reasoning:thread-1:item-reasoning",
kind: "reasoning",
summary: "Thought",
createdAt: "2026-01-01T00:00:00.000Z",
payload: {
detail: "Checking the source.",
streaming: false,
},
});
});

it("uses assistant item completion detail when no assistant deltas were streamed", async () => {
const harness = await createHarness();
const now = "2026-01-01T00:00:00.000Z";
Expand Down
Loading
Loading