Skip to content
Closed
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
12 changes: 11 additions & 1 deletion apps/api/src/shared-chat-dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@ function eligibleTargets(sessions: PaneCliSession[], panesForRoom: Map<string, P
return targets;
}

function compareTimestampsDesc(left: string, right: string): number {
const diff = Date.parse(right) - Date.parse(left);
return Number.isNaN(diff) ? right.localeCompare(left) : diff;
}

export async function pickSharedChatDispatchTarget(
store: SpaceStore,
runtimeId: string
Expand All @@ -86,7 +91,12 @@ export async function pickSharedChatDispatchTarget(
}
const targets = eligibleTargets(sessions, panesForRoom);
if (!targets.length) return null;
targets.sort((left, right) => right.session.updatedAt.localeCompare(left.session.updatedAt));
targets.sort(
(left, right) =>
compareTimestampsDesc(left.session.updatedAt, right.session.updatedAt) ||
compareTimestampsDesc(left.session.startedAt, right.session.startedAt) ||
right.session.sessionId.localeCompare(left.session.sessionId)
);
return targets[0] ?? null;
}

Expand Down
63 changes: 63 additions & 0 deletions apps/api/tests/shared-chat-dispatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,69 @@ describe("pickSharedChatDispatchTarget", () => {
const target = await pickSharedChatDispatchTarget(store, "cli:deepseek");
expect(target).not.toBeNull();
expect(target!.pane.id).toBe(newer.pane.id);
expect(target!.session.updatedAt).toBe("2026-08-15T10:00:00.000Z");
});

it("breaks ties deterministically by startedAt and sessionId when updatedAt timestamps match", async () => {
const store = new InMemorySpaceStore();
const paneA = createIdleDeepseekPane(store, "Deepseek A");
const paneB = createIdleDeepseekPane(store, "Deepseek B");
for (const [pane, sessionId] of [
[paneA, "cli_session:alpha"],
[paneB, "cli_session:beta"]
] as const) {
store.createPaneCliSession({
sessionId,
paneId: pane.pane.id,
roomId: pane.room.id,
actorUserId: "user_owner",
runtimeId: "cli:deepseek",
providerId: "deepseek",
modelId: "deepseek-chat",
thinkingEffort: "medium",
command: "opencode",
args: [],
cwd: "/workspace",
pid: 4200,
status: "running",
statusReason: null,
exitCode: null,
isActive: true,
startedAt: "2026-08-15T08:00:00.000Z",
updatedAt: "2026-08-15T10:00:00.000Z",
endedAt: null
});
}

const target = await pickSharedChatDispatchTarget(store, "cli:deepseek");
expect(target).not.toBeNull();
expect(target!.pane.id).toBe(paneB.pane.id);
});

it("compares ISO timestamps by actual instant across timezone offsets", async () => {
const store = new InMemorySpaceStore();
const olderWithOffset = createIdleDeepseekPane(store, "Deepseek Offset (+02:00)");
const newerUtc = createIdleDeepseekPane(store, "Deepseek UTC (Z)");
// 11:30+02:00 is 09:30Z, which is 30 minutes earlier than 10:00Z despite sorting later lexicographically
createRunningSession(
store,
olderWithOffset.room.id,
olderWithOffset.pane.id,
"cli:deepseek",
"2026-08-15T11:30:00.000+02:00"
);
createRunningSession(
store,
newerUtc.room.id,
newerUtc.pane.id,
"cli:deepseek",
"2026-08-15T10:00:00.000Z"
);

const target = await pickSharedChatDispatchTarget(store, "cli:deepseek");
expect(target).not.toBeNull();
expect(target!.pane.id).toBe(newerUtc.pane.id);
expect(target!.session.updatedAt).toBe("2026-08-15T10:00:00.000Z");
});

it("only considers the requested runtime", async () => {
Expand Down
4 changes: 3 additions & 1 deletion packages/contracts/src/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2401,7 +2401,9 @@ export const createPaneCliSessionInputSchema = paneCliSessionSchema
codexThreadId: codexThreadIdSchema.nullable().default(null),
status: paneCliSessionStatusSchema.optional(),
statusReason: z.string().trim().min(1).max(500).nullable().optional(),
isActive: z.boolean().optional()
isActive: z.boolean().optional(),
startedAt: isoDateTimeSchema.optional(),
updatedAt: isoDateTimeSchema.optional()
});

export const updatePaneCliSessionInputSchema = z
Expand Down
6 changes: 4 additions & 2 deletions packages/runtime/src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6422,6 +6422,8 @@ export class InMemorySpaceStore implements SpaceStore {
throw new SpaceConflictError("CLI task revision does not belong to the selected logical task.");
}
}
const startedAt = parsed.startedAt ?? timestamp;
const updatedAt = parsed.updatedAt ?? startedAt;
const session = paneCliSessionSchema.parse({
...parsed,
sessionId,
Expand All @@ -6434,8 +6436,8 @@ export class InMemorySpaceStore implements SpaceStore {
statusReason: parsed.statusReason ?? null,
exitCode: null,
isActive: parsed.isActive ?? true,
startedAt: timestamp,
updatedAt: timestamp,
startedAt,
updatedAt,
endedAt: null
});
if (session.isActive) {
Expand Down