From 3e81f64415abec41ea8640b2c15b7b5bfd569211 Mon Sep 17 00:00:00 2001 From: agent_coder Date: Sun, 12 Jul 2026 06:53:59 +0300 Subject: [PATCH] fix(work-time): user idle breaks agent burst; cap tz length (#395) Only an agent-sourced idle pulse extends an agent burst; a user idle (human supervision) now falls through to the human branch so the session is classified `work`, not `agent_only`. Add @MaxLength(64) to tz to match its comment. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/server/src/core/page/dto/page.dto.ts | 2 ++ .../page/work-time/compute-work-time.spec.ts | 23 +++++++++++++++++++ .../core/page/work-time/compute-work-time.ts | 16 +++++++++---- 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/apps/server/src/core/page/dto/page.dto.ts b/apps/server/src/core/page/dto/page.dto.ts index 5dfc5a1c..54483b51 100644 --- a/apps/server/src/core/page/dto/page.dto.ts +++ b/apps/server/src/core/page/dto/page.dto.ts @@ -5,6 +5,7 @@ import { IsOptional, IsString, IsUUID, + MaxLength, } from 'class-validator'; import { Transform } from 'class-transformer'; @@ -54,6 +55,7 @@ export class PageWorkTimeDto extends PageIdDto { // throws on an unknown zone (caught by the controller → 400). @IsOptional() @IsString() + @MaxLength(64) tz?: string; } diff --git a/apps/server/src/core/page/work-time/compute-work-time.spec.ts b/apps/server/src/core/page/work-time/compute-work-time.spec.ts index 97fa906e..da73808e 100644 --- a/apps/server/src/core/page/work-time/compute-work-time.spec.ts +++ b/apps/server/src/core/page/work-time/compute-work-time.spec.ts @@ -159,6 +159,29 @@ describe('computeWorkTime', () => { expect(r.agentOnlyMs).toBe(18 * MIN); }); + it('a USER-sourced idle breaks an agent burst → session is work, not agent_only', () => { + // A human supervision idle inherits source=user (aiChatId:null) and must NOT + // be swallowed into the agent burst. Δ=3m is within the default agentTGap so + // the two samples stay one session — but its class flips to `work`. + const rows = [ + s('2026-07-04T10:00:00', { source: 'agent', chat: 'c1', kind: 'agent' }), + s('2026-07-04T10:03:00', { source: 'user', chat: null, kind: 'idle' }), + ]; + const r = computeWorkTime(rows); + expect(r.sessions).toHaveLength(1); + expect(r.sessions[0].class).toBe('work'); + expect(r.workMs).toBeGreaterThan(0); + // The human idle is NOT captured as agent_only time. + expect(r.agentOnlyMs).toBe(0); + // Σ over `work` sessions == workMs and Σ over `agent_only` == agentOnlyMs. + const sum = (cls: string) => + r.sessions + .filter((x) => x.class === cls) + .reduce((acc, x) => acc + (x.end - x.start), 0); + expect(sum('work')).toBe(r.workMs); + expect(sum('agent_only')).toBe(r.agentOnlyMs); + }); + it('idle pulse keeps a human writing session visible (not excluded)', () => { const rows = [ s('2026-07-04T10:00:00'), diff --git a/apps/server/src/core/page/work-time/compute-work-time.ts b/apps/server/src/core/page/work-time/compute-work-time.ts index 349424fc..fa93c3c0 100644 --- a/apps/server/src/core/page/work-time/compute-work-time.ts +++ b/apps/server/src/core/page/work-time/compute-work-time.ts @@ -81,8 +81,9 @@ function normalize( * dense burst (8 snapshots in 7 minutes) contributes its wall-clock, not a count * × block. A burst is broken by any sample NOT continuing the same aiChatId * agent run: a non-agent sample, a `boundary` (actor transition), or a DIFFERENT - * aiChatId. An `idle` pulse with the SAME or a null aiChatId continues the burst - * (its label lags the real edit ≤ maxWait, well within rounding). + * aiChatId. Only an AGENT-sourced `idle` pulse with the SAME or a null aiChatId + * continues the burst (its label lags the real edit ≤ maxWait, well within + * rounding); a user-sourced `idle` (a human supervision pulse) breaks it. */ function collapse(samples: NormSample[], config: WorkTimeConfig): Segment[] { const segments: Segment[] = []; @@ -100,8 +101,15 @@ function collapse(samples: NormSample[], config: WorkTimeConfig): Segment[] { }; for (const s of samples) { - // idle pulse continuing the current agent burst (same or null run id). - if (burst && s.kind === 'idle' && (s.aiChatId === burst.chatId || s.aiChatId == null)) { + // An agent-sourced idle pulse continues the current agent burst (same or + // null run id). A user-sourced idle (human supervision) must NOT be swallowed + // here — it falls through to the human branch so the session flips to `work`. + if ( + burst && + s.kind === 'idle' && + s.isAgent && + (s.aiChatId === burst.chatId || s.aiChatId == null) + ) { burst.tEnd = s.t; continue; }