mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
* fix(od-next): continue plans with keyed markers and honor explicit deliverables * fix(od-next): retire output contracts and hand off continued tasks * fix(od-next): record round settlement and recover production cold * fix(od-next): resume marker sessions with context deltas * fix(od-next): separate completion facts and honor plan-only intent * fix(od-next): preserve physical failures and legacy chat compatibility * test(web): align run recovery fixtures and await campaign mount * fix(od-next): tolerate terminal production marker formatting * fix(od-next): unblock follow-ups after a stop or unreadable history (#8423) Treat a previous task this build cannot read as absent so the follow-up starts a new task, and stop counting a Run with a pending cancel as holding its conversation. Add end-to-end coverage for the marker continuation flow and restore the remote Workspace directory gate cases. * feat(od-next): add deliverable discovery and ten-type routing * fix(od-next): refine discovery and live delivery evidence * feat(observability): expose actual task delivery receipts for evaluation * fix(observability): settle successful headless task telemetry * docs: clarify focused local validation Keep local daemon and web validation scoped to affected files and integration boundaries. Reserve full suites for PR CI unless the user explicitly requests them. * feat: add Motion Design skill and OD Next task route * fix(web): rename motion design entry and place it second * fix(web): reconcile motion design catalog consumers * feat(web): add motion design templates to Home and Community Five motion design example templates (3D procedural island, explainer, product launch, showreel, data story), chosen by community reach, under the Home motion-design chip and a new Community "Motion design" type. Each template follows the bundled example layout: an OD-generated example.html with its runtime libraries under assets/, an HTML preview baked by the existing preview pipeline, and a SKILL.md that carries example.html as the reference when the card is picked on the OD Next motion-design route. Home cards ship curated copy for every UI locale. Motion templates stay off the Video and HyperFrames chips. * fix(skills): keep motion-design procedural layers visible in video export The MP4 exporter seeks timelines with callbacks suppressed (renderSeek(t, { suppressEvents: true })), so content drawn only from a GSAP onUpdate callback is missing from exported video. Recommend a per-frame draw loop that reads the timeline clock, and state that visible content must not depend on timeline callbacks. The OD Next task asset is re-synced with scripts/sync-motion-design-skill.ts. * fix(motion): preserve reference assets and exported animation frames * feat(skills): integrate curated motion helpers with complete runtime files * feat(skills): add Huashu, Oil Motion and Motion.Lab adapters * test: classify animate design guidance in narration guard * feat(motion): refine five community templates and portable previews * fix(motion): refine launch transitions and center gallery previews * fix: allow free-form briefs on automatic motion task route * feat(motion): add template soundtracks and audio export * fix(motion): require rendered MP4 alongside editable video source * test(motion): align strategy activation expectation with package version * fix: scope motion delivery and make editable sources playable * fix(motion): bundle source dependencies and valid closing protocol * feat(motion): rebuild the five templates on the most-viewed community posts Re-select topics by single-post views in the latest awesome-opus-5.5-video snapshot and anchor each template to the top eligible post of its genre: camera-focus explainer, showreel, product launch, procedural island and a new wordless animated short that replaces the data story. All five example.html files are hand-authored canvas/Three.js films drawn by one render(t) from the GSAP master timeline, so preview, scrubbing and MP4 export produce identical frames. Each ships an original procedural soundtrack, updated SKILL.md and 19-locale copy; Home order follows reach. * fix(plugins): load bundled fonts in plugin previews Plugin previews blocked every bundled font: the preview CSP had no font-src, and relative url() references inside inline <style> blocks kept pointing at the preview URL instead of the plugin folder. Allow same-origin fonts, map inline-style url() references through the existing asset route (escapes, absolute and data: URLs stay untouched), and serve font files with font MIME types. * feat(community): make motion template Use match the Home example pick Community 「做同款」 on a motion design template now binds it as that type's own example card, carrying the type's project metadata, so the run takes the same OD Next motion-design route as a pick from Home's motion design list and delivers the MP4 together with the editable source. Other task types keep the existing plugin-pinning hand-off. Community use clicks now record use_source (card | detail_modal) and use_action (use | use_with_query); the details modal Use is tracked for the first time. The Home hand-off no longer records a second community_gallery click for uses Community already tracked. * fix(plugins): count the five motion templates in the official registry metadata * fix(media): keep HyperFrames audio filters compatible with FFmpeg 4.1 CI's bundled Linux FFmpeg 4.1 rejects adelay all=1 and amix normalize=0. Make each track stereo and delay both channels explicitly, skip amix for a single track, and undo amix averaging with a gain for several tracks. * fix(motion): keep template previews bakeable on slow CI renderers CI's software-GL baker skipped the 3D island (preview readiness timeout): under slow frames GSAP lag smoothing slowed the timeline, so the 8 s poster frame never arrived within the baker's 15 s wait. Play every template on the wall clock and move the island poster to 6 s. With simulated 300 ms frames all five now report ready within 10 s. * feat(motion): add the kinetic typography template Template selection now follows the motion design spec's four content types (brand and product films, kinetic typography, explainer animation, procedural art). Kinetic typography takes the fifth slot, replacing the animated short. The new template is a 15-second, 120 BPM type film with an original soundtrack and 19-locale copy. * fix(motion): let the project motion player fit template stages Inside a project the motion source player wraps the composition, fits it and adds its own controls. The templates also scaled their stage, so editable sources from Remix and Community use rendered at a fraction of the preview. Templates now keep their own fit and controls only outside that player. * feat(motion): restage the 3D island as a midday lagoon The island moves from golden hour to a bright tropical midday closer to its anchor post: a shader lagoon coloured by a baked depth map with ripples, shallow-water shimmer and shore foam, a wide white-sand beach, palms and instanced bushes, a village with a pier, soft sun shadows and a lower camera path. The CPU wave mesh is gone, so frames cost no more than before. Copy is updated in all 19 locales. * fix(motion): keep each soundtrack inside the composition root A Community use regenerated the island source tail and dropped the root <audio> element, so its MP4 had no sound. Templates now place the element inside the composition root next to the stage. * docs(motion): state the template selection rule as complete works * fix(motion): enforce video clocks and bound content verification * feat(motion): replace the motion templates with remakes of viral posts The five Motion design templates are now frame-faithful remakes of viral motion posts, so each preview shows the look users came for: the 15-second motion designer showreel, a high-energy motion reel (copy set in English), a one-shape UI motion piece, a pixel-art wizard and a 20-second electric-blue tech reel. Each ships open-licensed fonts, an original soundtrack, the creator's published prompt where one exists, and credits its source post. The explainer, product launch, 3D island and kinetic type templates are removed. Tests follow the new set; a backward-seek check now covers hidden WebGL layers. * feat(motion): feature The Future Is Already Here first and start preview sound with the film * chore: ensure PR CI * fix(motion): match The Future Is Already Here controls and reorder the motion templates * fix(motion): make the one-shape UI preview controls legible * fix(motion): hide The Future Is Already Here controls inside the project player * fix(od-next): maintain discovery prompt assets in English * fix(motion): plan suitable sound and synchronize source audio playback * feat(motion): keep remake soundtracks by default and fade exported audio where it stops * test(motion): cover audible source controls in Chromium * fix(test): use explicit ESM imports in audio player regression * fix(od-next): preserve planning intent and make agent CLI probes inert * fix(motion): align preview track discovery with video export * feat(motion): autoplay looping HTML without a player bar * fix(test): consume the built motion playback contract * docs(motion): state explicit soundtrack rules for make-same and the music line in replies * chore(motion): version the audible looping delivery profile * docs(motion): let template sound rules lead the general policy and freeze the audio contract * fix(motion): scope autoplay to declared video motion sources * fix(motion): reject audible MP4s under an explicit silent contract * docs(motion): drop the routine music note from make-same replies * test(motion): align delivery regressions with looping source playback * fix(od-next): route refreshable sources and preserve live templates * fix(od-next): distinguish source refresh from preview reload * fix(motion): isolate preview and audio export from legacy HyperFrames * fix(od-next): retain live list containers for empty source data * test(motion): retain the existing desktop legacy audio limitation * fix(motion): suspend audio when previews leave the active workspace * feat(motion): replace two motion templates with a product promo and a visual lesson; sign the showreel as Open Design * fix(discovery): distinguish reference recreation from new prototypes * fix(discovery): require skill reads before copying reference artifacts * feat(motion): give the showreel the Open Design palette and a full-size stacked wordmark * feat(motion): give the product promo a livelier mascot girl * refactor(motion): rebuild The Future Is Already Here as readable, editable scene code * fix(motion): pick gallery cover frames that survive the baker's capture delay * feat(motion): sharpen the showreel opener, depth, truchet and end card * fix(discovery): avoid implementation-only skills and false video delivery * feat(motion): make the product promo mascot talk, add a speaking score and the flow pop * fix(discovery): distinguish video rendering from editable animation delivery * feat(motion): give the visual lesson the original's paper-collage texture * fix(motion): bundle gallery covers for fresh branch checkouts * feat(motion): feature and refine Bring Ideas to Life Adapt the paper story around OpenDesign capabilities, with short rounded hands, clearer timing, continuous paper actions, and a larger audience invitation. Feature it first in Motion and synchronize localized names, metadata, cover, guidance, and regressions. Keep product promo audio music-only with sparse percussion. * fix(motion): stabilize artifact cards and capture declared covers * fix(discovery): retain WebGL intent and isolate reference-only skills * fix(discovery): complete authorized plans and verify audible delivery * fix(discovery): gate skill reads on current production authorization * fix(web): share creation type order with community * fix(discovery): stop required-model attempts on authentication failure * fix(discovery): retain required model provider and billing channel * fix(discovery): stop opaque video failures without diagnostic generations * fix(discovery): distinguish local state and tighten output adherence * fix(preview): preserve local forms and hash routes in strict sandbox * fix(prototype): keep validation feedback selectors consistent * fix(prototype): preserve native hidden feedback states * fix(discovery): align local routes and live filter states * fix(prototype): preserve dynamic controls and dialog visibility * fix(prototype): keep review and confirmation state coherent * fix(web): stabilize preview activation and retain scoped local progress * test(web): verify persistent previews through activation messages * feat(web): refine task type selection and keyboard navigation * fix(discovery): distinguish execution from requested delivery and expose recovery * fix(web): use final run delivery state in folded task history * revert(web): restore todo-based continuation controls * revert(od-next): remove experimental continuation recovery changes * fix(telemetry): bind Task traces to authenticated Vela identity * test(telemetry): establish accepted Task roots in bridge fixtures * test(discovery): align required CI fixtures with integrated behavior * fix(web): preserve succeeded messages when run probe is unavailable * test(discovery): verify selection clearing and production turn recovery --------- Co-authored-by: bone3deep1962-collab <bone3deep1962@gmail.com> Co-authored-by: Cheems <hihicheems@gmail.com> Co-authored-by: Cheems <94773058+itscheems@users.noreply.github.com> Co-authored-by: zhouzheng <zhouzheng@refly.ai> Co-authored-by: William <zhouhaoran@refly.ai> Co-authored-by: open-design-crew[bot] <4197780+open-design-crew[bot]@users.noreply.github.com> Co-authored-by: NJUHua <113895241+NJUHua@users.noreply.github.com>
1146 lines
38 KiB
TypeScript
1146 lines
38 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest';
|
|
|
|
import {
|
|
FORM_ANSWERED_GENERIC_OVERRIDE,
|
|
composeChatUserRequestForAgent,
|
|
createFinalizedMessageTelemetryReporter,
|
|
shouldReportRunCompletedFromMessage,
|
|
shouldReportRunCompletionTelemetryFallbackStatus,
|
|
telemetryPromptFromRunRequest,
|
|
} from '../src/server.js';
|
|
|
|
describe('Langfuse message finalization gate', () => {
|
|
const terminalMessage = {
|
|
id: 'assistant-1',
|
|
role: 'assistant',
|
|
content: 'final answer',
|
|
runId: 'run-1',
|
|
runStatus: 'succeeded',
|
|
};
|
|
|
|
it('does not report when only terminal runStatus has been persisted', () => {
|
|
expect(
|
|
shouldReportRunCompletedFromMessage(terminalMessage, {
|
|
...terminalMessage,
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
it('reports only on the final telemetry-marked message write', () => {
|
|
expect(
|
|
shouldReportRunCompletedFromMessage(terminalMessage, {
|
|
...terminalMessage,
|
|
producedFiles: [],
|
|
telemetryFinalized: true,
|
|
}),
|
|
).toBe(true);
|
|
});
|
|
|
|
it('ignores non-terminal run statuses even if marked finalized', () => {
|
|
expect(
|
|
shouldReportRunCompletedFromMessage(
|
|
{ ...terminalMessage, runStatus: 'running' },
|
|
{ telemetryFinalized: true },
|
|
),
|
|
).toBe(false);
|
|
});
|
|
|
|
it('schedules terminal fallback only for failed and canceled runs', () => {
|
|
expect(shouldReportRunCompletionTelemetryFallbackStatus('failed')).toBe(true);
|
|
expect(shouldReportRunCompletionTelemetryFallbackStatus('canceled')).toBe(true);
|
|
expect(shouldReportRunCompletionTelemetryFallbackStatus('succeeded')).toBe(false);
|
|
expect(shouldReportRunCompletionTelemetryFallbackStatus('running')).toBe(false);
|
|
});
|
|
|
|
it('also schedules successful Task-owned runs without a client final-message write', () => {
|
|
expect(shouldReportRunCompletionTelemetryFallbackStatus('succeeded', true)).toBe(true);
|
|
expect(shouldReportRunCompletionTelemetryFallbackStatus('succeeded', false)).toBe(false);
|
|
expect(shouldReportRunCompletionTelemetryFallbackStatus('running', true)).toBe(false);
|
|
});
|
|
|
|
it('uses the explicit current prompt for telemetry instead of the full transcript', () => {
|
|
expect(
|
|
telemetryPromptFromRunRequest(
|
|
'## user\npre-consent brief\n\n## assistant\ndraft\n\n## user\npost-consent revision',
|
|
'post-consent revision',
|
|
),
|
|
).toBe('post-consent revision');
|
|
});
|
|
|
|
it('falls back to the legacy message when currentPrompt is absent', () => {
|
|
expect(telemetryPromptFromRunRequest('legacy prompt', undefined)).toBe(
|
|
'legacy prompt',
|
|
);
|
|
});
|
|
|
|
it('promotes discovery form answers without suppressing new material clarification', () => {
|
|
const currentPrompt = [
|
|
'[form answers \u2014 discovery]',
|
|
'- output: Dashboard / tool UI',
|
|
'- brand: Pick a direction for me [value: pick_direction]',
|
|
].join('\n');
|
|
const prompt = composeChatUserRequestForAgent(
|
|
'## user\ninitial brief\n\n## assistant\n<form/>',
|
|
currentPrompt,
|
|
);
|
|
|
|
expect(prompt).toContain('## Latest user turn - form answers submitted');
|
|
expect(prompt).toContain(currentPrompt);
|
|
expect(prompt).toContain('The user has answered the discovery form.');
|
|
expect(prompt).toContain(
|
|
'Do not re-emit the answered form or repeat fields it already answered.',
|
|
);
|
|
expect(prompt).toContain(
|
|
'Only if a new, materially blocking requirement remains unresolved',
|
|
);
|
|
expect(prompt.indexOf('## Full conversation transcript')).toBeGreaterThan(
|
|
prompt.indexOf(currentPrompt),
|
|
);
|
|
});
|
|
|
|
it('task-type form answers trigger the build transition just like discovery', () => {
|
|
const prompt = composeChatUserRequestForAgent(
|
|
'## user\ninitial brief',
|
|
'[form answers - task-type]\n- taskType: Slide deck',
|
|
);
|
|
|
|
expect(prompt).toContain('The user has answered the task-type form.');
|
|
expect(prompt).toContain('continue with RULE 2 / RULE 3 or the matching active workflow');
|
|
expect(prompt).not.toContain('Treat these form answers as the active user turn');
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
header: '[form answers: task-type]',
|
|
expectedFormId: 'task-type',
|
|
expectedTransition: 'continue with RULE 2 / RULE 3',
|
|
},
|
|
{
|
|
header: '[form answers]',
|
|
expectedFormId: 'form',
|
|
expectedTransition: 'Treat these form answers as the active user turn',
|
|
},
|
|
])(
|
|
'accepts the supported $header form-answer header',
|
|
({ header, expectedFormId, expectedTransition }) => {
|
|
const prompt = composeChatUserRequestForAgent(
|
|
'## user\ninitial brief',
|
|
`${header}\n- taskType: Slide deck`,
|
|
);
|
|
|
|
expect(prompt).toContain(`The user has answered the ${expectedFormId} form.`);
|
|
expect(prompt).toContain(expectedTransition);
|
|
},
|
|
);
|
|
|
|
it('unknown form ids get the generic transition without forcing the build', () => {
|
|
const prompt = composeChatUserRequestForAgent(
|
|
'## user\ninitial brief',
|
|
'[form answers - preferences]\n- theme: dark',
|
|
);
|
|
|
|
expect(prompt).toContain('The user has answered the preferences form.');
|
|
expect(prompt).toContain('Treat these form answers as the active user turn');
|
|
expect(prompt).not.toContain('continue with RULE 2 / RULE 3');
|
|
});
|
|
|
|
// `agy -c` carries its own conversation memory, so packing the
|
|
// rendered web transcript (the `## user` / `## assistant` blocks)
|
|
// into the user request duplicates context the upstream CLI already
|
|
// has — AND the embedded copy includes the literal `<question-form>`
|
|
// markup the agent emitted earlier, which the model can then re-emit
|
|
// after it is answered, looking like the discovery form loop never breaks.
|
|
// With `skipTranscript: true`, only the latest user turn ships and
|
|
// the misleading "## Full conversation transcript" header is dropped.
|
|
it('drops the transcript and transcript header when skipTranscript is true', () => {
|
|
const currentPrompt = [
|
|
'[form answers — discovery]',
|
|
'- output: Dashboard / tool UI',
|
|
'- brand: Pick a direction for me [value: pick_direction]',
|
|
].join('\n');
|
|
const transcript = [
|
|
'## user',
|
|
'初始需求',
|
|
'',
|
|
'## assistant',
|
|
'<question-form id="discovery">…</question-form>',
|
|
'',
|
|
'## user',
|
|
currentPrompt,
|
|
].join('\n');
|
|
|
|
const prompt = composeChatUserRequestForAgent(transcript, currentPrompt, {
|
|
skipTranscript: true,
|
|
});
|
|
|
|
// The form-answer transition still fires — that drives RULE 2 / 3.
|
|
expect(prompt).toContain('The user has answered the discovery form.');
|
|
// The latest user turn is preserved verbatim.
|
|
expect(prompt).toContain(currentPrompt);
|
|
// The transcript header is dropped — it was misleading because the
|
|
// body underneath is no longer a transcript.
|
|
expect(prompt).not.toContain('## Full conversation transcript');
|
|
// The prior assistant turn's `<question-form>` markup must NOT
|
|
// leak in — that's the form-loop regression we're guarding.
|
|
// (The transition block legitimately mentions "<question-form>"
|
|
// in prose, so the assertion targets the opening tag the prior
|
|
// turn carried, not the bare substring.)
|
|
expect(prompt).not.toContain('<question-form id="discovery">');
|
|
expect(prompt).not.toContain('## assistant');
|
|
});
|
|
|
|
// Issue #6239: the form-answer transition block already embeds the
|
|
// trimmed `currentPrompt` verbatim, so appending `body = currentPrompt`
|
|
// after it on the resume (skipTranscript) path shipped the submitted
|
|
// answers twice in the same `# User request`.
|
|
it('ships the submitted form answers exactly once on the resume (skipTranscript) path', () => {
|
|
const currentPrompt = [
|
|
'[form answers — discovery]',
|
|
'- output: Dashboard / tool UI',
|
|
'- brand: Pick a direction for me [value: pick_direction]',
|
|
].join('\n');
|
|
const transcript = [
|
|
'## user',
|
|
'initial brief',
|
|
'',
|
|
'## assistant',
|
|
'<question-form id="discovery">…</question-form>',
|
|
'',
|
|
'## user',
|
|
currentPrompt,
|
|
].join('\n');
|
|
|
|
const prompt = composeChatUserRequestForAgent(transcript, currentPrompt, {
|
|
skipTranscript: true,
|
|
});
|
|
|
|
expect(prompt.split(currentPrompt).length - 1).toBe(1);
|
|
});
|
|
|
|
// The aggressive form-answered OVERRIDE block is what tells weak
|
|
// plain agents (GPT-OSS-120B Medium, Gemini 3.5 Flash) to skip
|
|
// RULE 1's form example on follow-up turns. We pin the trigger
|
|
// condition AND the specific anti-patterns the literal carries,
|
|
// because silently weakening any of them — e.g. dropping the
|
|
// markdown-fence ban or the "subagents stopped" hallucination ban —
|
|
// reintroduces the form-echo regression we hit in PR #3157 on GPT-OSS.
|
|
it('FORM_ANSWERED_SYSTEM_OVERRIDE pins the anti-patterns weak plain agents need spelled out', async () => {
|
|
const { FORM_ANSWERED_SYSTEM_OVERRIDE } = await import('../src/server.js');
|
|
|
|
expect(FORM_ANSWERED_SYSTEM_OVERRIDE).toContain(
|
|
'## OVERRIDE — submitted form answers are authoritative',
|
|
);
|
|
expect(FORM_ANSWERED_SYSTEM_OVERRIDE).not.toContain('turn 2 or later');
|
|
expect(FORM_ANSWERED_SYSTEM_OVERRIDE).toContain(
|
|
'RULE 1 does not require another form merely because its\nexample appears',
|
|
);
|
|
|
|
// Forbidden anti-patterns observed in real captures:
|
|
expect(FORM_ANSWERED_SYSTEM_OVERRIDE).toContain(
|
|
'Re-emitting the answered `discovery` or `task-type` form',
|
|
);
|
|
expect(FORM_ANSWERED_SYSTEM_OVERRIDE).toContain('```json fenced block');
|
|
expect(FORM_ANSWERED_SYSTEM_OVERRIDE).toContain('Form-asking prose that repeats');
|
|
expect(FORM_ANSWERED_SYSTEM_OVERRIDE).toContain('"subagents stopped"');
|
|
|
|
// Required path: use the submitted answers and keep moving, while
|
|
// preserving on-demand clarification for a genuinely new blocker.
|
|
expect(FORM_ANSWERED_SYSTEM_OVERRIDE).toContain('RULE 2');
|
|
expect(FORM_ANSWERED_SYSTEM_OVERRIDE).toContain('RULE 3');
|
|
expect(FORM_ANSWERED_SYSTEM_OVERRIDE).toContain(
|
|
'Only if a new, materially blocking requirement remains unresolved',
|
|
);
|
|
expect(FORM_ANSWERED_SYSTEM_OVERRIDE).not.toContain(
|
|
'A `<question-form>` tag of any id',
|
|
);
|
|
});
|
|
|
|
it('FORM_ANSWERED_GENERIC_OVERRIDE is used for non-discovery/task-type form ids', () => {
|
|
// Non-build-transition forms should get a smaller override that only
|
|
// suppresses re-asking — not the RULE 2 / RULE 3 / artifact directive.
|
|
expect(FORM_ANSWERED_GENERIC_OVERRIDE).toContain(
|
|
'## OVERRIDE — submitted form answers are authoritative',
|
|
);
|
|
expect(FORM_ANSWERED_GENERIC_OVERRIDE).not.toContain('turn 2 or later');
|
|
expect(FORM_ANSWERED_GENERIC_OVERRIDE).toContain('Do not ask the same form again');
|
|
expect(FORM_ANSWERED_GENERIC_OVERRIDE).toContain('new, materially');
|
|
// Must NOT contain the artifact-build directive that only applies to
|
|
// discovery / task-type — sending it for an unrelated form id would give
|
|
// the model contradictory instructions.
|
|
expect(FORM_ANSWERED_GENERIC_OVERRIDE).not.toContain('RULE 2');
|
|
expect(FORM_ANSWERED_GENERIC_OVERRIDE).not.toContain('RULE 3');
|
|
expect(FORM_ANSWERED_GENERIC_OVERRIDE).not.toContain('`<artifact>`');
|
|
});
|
|
|
|
it('FORM_ANSWERED_SYSTEM_OVERRIDE only fires through composeChatUserRequestForAgent\'s transition gate', async () => {
|
|
// Defense-in-depth check: a turn that is NOT a form-answer follow-up
|
|
// (no `[form answers — …]` header in `currentPrompt`) must not
|
|
// surface any of the OVERRIDE language, even when `message` carries
|
|
// a transcript that mentions question-form. Otherwise we'd suppress
|
|
// the legitimate turn-1 form ask.
|
|
const transcript = '## user\n初始需求\n\n## assistant\n<question-form id="discovery">...</question-form>';
|
|
const currentPrompt = '继续做点修改';
|
|
|
|
const prompt = composeChatUserRequestForAgent(transcript, currentPrompt);
|
|
expect(prompt).not.toContain('OVERRIDE — submitted form answers are authoritative');
|
|
expect(prompt).not.toContain('RULE 1 does not require another form');
|
|
});
|
|
|
|
it('also drops the transcript on a non-form turn when skipTranscript is true', () => {
|
|
// Without a form-answer transition, the function previously returned
|
|
// `message` verbatim. With skipTranscript the body must come from
|
|
// `currentPrompt` instead so a follow-up `agy -c` turn doesn't carry
|
|
// the duplicate transcript.
|
|
const transcript = '## user\n第一轮\n\n## assistant\n回答\n\n## user\n第二轮 follow-up';
|
|
const currentPrompt = '第二轮 follow-up';
|
|
|
|
const skipped = composeChatUserRequestForAgent(transcript, currentPrompt, {
|
|
skipTranscript: true,
|
|
});
|
|
expect(skipped).toBe(currentPrompt);
|
|
|
|
// Default behavior unchanged (backward compatibility for every
|
|
// adapter that doesn't set resumesSessionViaCli).
|
|
const kept = composeChatUserRequestForAgent(transcript, currentPrompt);
|
|
expect(kept).toBe(transcript);
|
|
});
|
|
|
|
it('uses a headless message as the latest native-resume turn when currentPrompt is absent', () => {
|
|
const message = 'Revise index.html from Arca to Moonleaf.';
|
|
|
|
expect(composeChatUserRequestForAgent(message, undefined, {
|
|
skipTranscript: true,
|
|
})).toBe(message);
|
|
});
|
|
|
|
it('preserves an explicitly empty currentPrompt on native resume', () => {
|
|
expect(composeChatUserRequestForAgent('legacy fallback must not leak', '', {
|
|
skipTranscript: true,
|
|
})).toBe('(No extra typed instruction.)');
|
|
});
|
|
|
|
it('invokes Langfuse reporting once when the final message write is marked', () => {
|
|
const run = {
|
|
id: 'run-1',
|
|
projectId: 'project-1',
|
|
conversationId: 'conv-1',
|
|
assistantMessageId: 'assistant-1',
|
|
status: 'succeeded',
|
|
createdAt: 1,
|
|
updatedAt: 2,
|
|
events: [],
|
|
};
|
|
const report = vi.fn();
|
|
const reporter = createFinalizedMessageTelemetryReporter({
|
|
design: { runs: { get: vi.fn(() => run) } },
|
|
db: 'db',
|
|
dataDir: '/tmp/od-data',
|
|
reportedRuns: new Set<string>(),
|
|
getAppVersion: () => ({ version: '0.7.0', channel: 'beta', packaged: true }),
|
|
report,
|
|
});
|
|
|
|
reporter(
|
|
{ ...terminalMessage, endedAt: 1234 },
|
|
{ telemetryFinalized: true },
|
|
);
|
|
reporter(
|
|
{ ...terminalMessage, endedAt: 1234 },
|
|
{ telemetryFinalized: true },
|
|
);
|
|
|
|
expect(report).toHaveBeenCalledTimes(1);
|
|
expect(report).toHaveBeenCalledWith({
|
|
db: 'db',
|
|
dataDir: '/tmp/od-data',
|
|
run,
|
|
persistedRunStatus: 'succeeded',
|
|
persistedEndedAt: 1234,
|
|
appVersion: { version: '0.7.0', channel: 'beta', packaged: true },
|
|
});
|
|
});
|
|
|
|
it('does not send again when a final message arrives after terminal fallback reporting', async () => {
|
|
const run = {
|
|
id: 'run-failed-late-final',
|
|
projectId: 'project-1',
|
|
conversationId: 'conv-1',
|
|
assistantMessageId: 'assistant-1',
|
|
status: 'failed',
|
|
createdAt: 1,
|
|
updatedAt: 2,
|
|
events: [],
|
|
};
|
|
const capture = vi.fn<(event: { insertId?: string }) => void>();
|
|
const report = vi.fn(async (_args: any) => ({
|
|
langfuse_expected: true,
|
|
langfuse_delivery_status: 'accepted' as const,
|
|
}));
|
|
const reporter = createFinalizedMessageTelemetryReporter({
|
|
design: {
|
|
analytics: { capture },
|
|
getAppVersion: () => '0.7.0',
|
|
runs: { get: vi.fn(() => run) },
|
|
},
|
|
db: 'db',
|
|
dataDir: '/tmp/od-data',
|
|
reportedRuns: new Set<string>(),
|
|
report,
|
|
});
|
|
|
|
reporter(
|
|
{ ...terminalMessage, runId: run.id, runStatus: 'failed', endedAt: 1234 },
|
|
{ telemetryFinalized: true },
|
|
{
|
|
analyticsContext: {
|
|
deviceId: 'device-1',
|
|
sessionId: 'session-1',
|
|
clientType: 'desktop',
|
|
locale: 'zh-CN',
|
|
requestId: 'request-1',
|
|
},
|
|
reportTrigger: 'terminal_fallback',
|
|
},
|
|
);
|
|
reporter(
|
|
{ ...terminalMessage, runId: run.id, runStatus: 'failed', endedAt: 1235 },
|
|
{ telemetryFinalized: true },
|
|
{
|
|
analyticsContext: {
|
|
deviceId: 'device-1',
|
|
sessionId: 'session-1',
|
|
clientType: 'desktop',
|
|
locale: 'zh-CN',
|
|
requestId: 'request-1',
|
|
},
|
|
reportTrigger: 'final_message',
|
|
},
|
|
);
|
|
reporter(
|
|
{ ...terminalMessage, runId: run.id, runStatus: 'failed', endedAt: 1236 },
|
|
{ telemetryFinalized: true },
|
|
{
|
|
analyticsContext: {
|
|
deviceId: 'device-1',
|
|
sessionId: 'session-1',
|
|
clientType: 'desktop',
|
|
locale: 'zh-CN',
|
|
requestId: 'request-1',
|
|
},
|
|
reportTrigger: 'final_message',
|
|
},
|
|
);
|
|
await Promise.resolve();
|
|
|
|
expect(report).toHaveBeenCalledTimes(1);
|
|
expect(report.mock.calls.map(([call]) => call.persistedEndedAt)).toEqual([
|
|
1234,
|
|
]);
|
|
expect(capture).toHaveBeenCalledTimes(3);
|
|
expect(capture.mock.calls.map(([call]) => call.insertId)).toEqual(expect.arrayContaining([
|
|
'run-failed-late-final-langfuse-report-terminal_fallback-accepted',
|
|
'run-failed-late-final-langfuse-report-final_message-skipped-duplicate_run',
|
|
'run-failed-late-final-langfuse-report-final_message-skipped-duplicate_run',
|
|
]));
|
|
});
|
|
|
|
it('captures Langfuse report acceptance after final message reporting resolves', async () => {
|
|
const run = {
|
|
id: 'run-accepted',
|
|
projectId: 'project-1',
|
|
conversationId: 'conv-1',
|
|
assistantMessageId: 'assistant-1',
|
|
agentId: 'codex',
|
|
model: 'default',
|
|
status: 'succeeded',
|
|
createdAt: 1,
|
|
updatedAt: 2,
|
|
events: [],
|
|
};
|
|
const capture = vi.fn();
|
|
const beginTelemetryDelivery = vi.fn(() => ({
|
|
version: 1,
|
|
idempotencyKey: 'od-run-telemetry-v1-fixture',
|
|
status: 'in_flight',
|
|
attemptCount: 1,
|
|
crashWindow: true,
|
|
startedAt: 1,
|
|
}));
|
|
const finalizeTelemetryDelivery = vi.fn();
|
|
const report = vi.fn(async () => ({
|
|
langfuse_expected: true,
|
|
langfuse_delivery_status: 'accepted' as const,
|
|
}));
|
|
const reporter = createFinalizedMessageTelemetryReporter({
|
|
design: {
|
|
analytics: { capture },
|
|
getAppVersion: () => '0.7.0',
|
|
runs: {
|
|
get: vi.fn(() => run),
|
|
beginTelemetryDelivery,
|
|
finalizeTelemetryDelivery,
|
|
},
|
|
},
|
|
db: 'db',
|
|
dataDir: '/tmp/od-data',
|
|
reportedRuns: new Set<string>(),
|
|
getAppVersion: () => ({ version: '0.7.0', channel: 'beta', packaged: true }),
|
|
report,
|
|
});
|
|
|
|
reporter(
|
|
{ ...terminalMessage, runId: run.id, endedAt: 1234 },
|
|
{ telemetryFinalized: true },
|
|
{
|
|
analyticsContext: {
|
|
deviceId: 'device-1',
|
|
sessionId: 'session-1',
|
|
clientType: 'desktop',
|
|
locale: 'zh-CN',
|
|
requestId: 'request-1',
|
|
},
|
|
projectId: 'project-1',
|
|
conversationId: 'conv-1',
|
|
reportTrigger: 'terminal_fallback',
|
|
},
|
|
);
|
|
await Promise.resolve();
|
|
|
|
expect(capture).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
eventName: 'langfuse_report_result',
|
|
insertId: 'run-accepted-langfuse-report-terminal_fallback-accepted',
|
|
properties: expect.objectContaining({
|
|
run_id: 'run-accepted',
|
|
langfuse_trace_id: 'run-accepted',
|
|
langfuse_expected: true,
|
|
langfuse_delivery_status: 'accepted',
|
|
langfuse_report_result: 'accepted',
|
|
langfuse_report_trigger: 'terminal_fallback',
|
|
}),
|
|
}),
|
|
);
|
|
expect(beginTelemetryDelivery).toHaveBeenCalledWith(run);
|
|
expect(finalizeTelemetryDelivery).toHaveBeenCalledWith(run, {
|
|
langfuse_expected: true,
|
|
langfuse_delivery_status: 'accepted',
|
|
});
|
|
expect(beginTelemetryDelivery.mock.invocationCallOrder[0]).toBeLessThan(
|
|
report.mock.invocationCallOrder[0]!,
|
|
);
|
|
expect(report).toHaveBeenCalledWith(expect.objectContaining({
|
|
deliveryIdempotencyKey: 'od-run-telemetry-v1-fixture',
|
|
}));
|
|
});
|
|
|
|
it('routes mapped send-mode runs to one task hierarchy without changing execution outcome', async () => {
|
|
const run = {
|
|
id: 'run-task-hierarchy',
|
|
projectId: 'project-1',
|
|
conversationId: 'conv-1',
|
|
assistantMessageId: 'assistant-1',
|
|
status: 'succeeded',
|
|
createdAt: 1,
|
|
updatedAt: 2,
|
|
events: [],
|
|
};
|
|
const beginTelemetryDelivery = vi.fn(() => ({
|
|
version: 1,
|
|
idempotencyKey: 'od-run-telemetry-v1-task-hierarchy',
|
|
status: 'in_flight',
|
|
attemptCount: 1,
|
|
crashWindow: true,
|
|
startedAt: 1,
|
|
}));
|
|
const finalizeTelemetryDelivery = vi.fn();
|
|
const completion = Promise.resolve({ action: 'sent' });
|
|
const beginFinalizeForRun = vi.fn(() => ({
|
|
durableTaskTruth: true,
|
|
suppressSingleRun: true,
|
|
completion,
|
|
}));
|
|
const finalizeForRun = vi.fn(async () => ({ action: 'sent' }));
|
|
const report = vi.fn();
|
|
const reportedRuns = new Set<string>();
|
|
const reporter = createFinalizedMessageTelemetryReporter({
|
|
design: {
|
|
runs: {
|
|
get: vi.fn(() => run),
|
|
beginTelemetryDelivery,
|
|
finalizeTelemetryDelivery,
|
|
},
|
|
},
|
|
db: 'db',
|
|
dataDir: '/tmp/od-data',
|
|
reportedRuns,
|
|
taskObservationRollout: {
|
|
modeForRun: vi.fn(() => 'send' as const),
|
|
representationForRun: vi.fn(() => 'task_accepted' as const),
|
|
beginFinalizeForRun,
|
|
finalizeForRun,
|
|
},
|
|
report,
|
|
});
|
|
|
|
reporter(
|
|
{ ...terminalMessage, runId: run.id, endedAt: 1234 },
|
|
{ telemetryFinalized: true },
|
|
);
|
|
await Promise.resolve();
|
|
|
|
expect(report).not.toHaveBeenCalled();
|
|
expect(beginFinalizeForRun).toHaveBeenCalledWith(run.id);
|
|
expect(finalizeForRun).not.toHaveBeenCalled();
|
|
expect(beginTelemetryDelivery).not.toHaveBeenCalled();
|
|
expect(finalizeTelemetryDelivery).not.toHaveBeenCalled();
|
|
expect(reportedRuns).toContain(run.id);
|
|
expect(run.status).toBe('succeeded');
|
|
});
|
|
|
|
it('does not upgrade an observed task to hierarchy send after restart', async () => {
|
|
const run: any = {
|
|
id: 'run-observe-then-send',
|
|
projectId: 'project-1',
|
|
conversationId: 'conv-1',
|
|
assistantMessageId: 'assistant-1',
|
|
status: 'succeeded',
|
|
createdAt: 1,
|
|
updatedAt: 2,
|
|
events: [],
|
|
};
|
|
const legacyReport = vi.fn(async () => ({
|
|
langfuse_expected: true,
|
|
langfuse_delivery_status: 'accepted' as const,
|
|
}));
|
|
const taskNetwork = vi.fn();
|
|
let observed = false;
|
|
const beginTelemetryDelivery = vi.fn(() =>
|
|
run.telemetryDelivery ?? {
|
|
version: 1,
|
|
idempotencyKey: 'od-run-telemetry-v1-observe-then-send',
|
|
status: 'in_flight',
|
|
attemptCount: 0,
|
|
crashWindow: true,
|
|
startedAt: 1,
|
|
});
|
|
const finalizeTelemetryDelivery = vi.fn((_run: any, delivery: any) => {
|
|
if (run.telemetryDelivery?.finalizedAt) return;
|
|
run.telemetryDelivery = {
|
|
version: 1,
|
|
idempotencyKey: 'od-run-telemetry-v1-observe-then-send',
|
|
status: delivery.langfuse_delivery_status,
|
|
attemptCount: delivery.langfuse_attempt_count ?? 1,
|
|
crashWindow: false,
|
|
finalizedAt: 2,
|
|
};
|
|
});
|
|
const design = {
|
|
runs: {
|
|
get: vi.fn(() => run),
|
|
beginTelemetryDelivery,
|
|
finalizeTelemetryDelivery,
|
|
},
|
|
};
|
|
const observeReporter = createFinalizedMessageTelemetryReporter({
|
|
design,
|
|
db: 'db',
|
|
dataDir: '/tmp/od-data',
|
|
reportedRuns: new Set<string>(),
|
|
taskObservationRollout: {
|
|
modeForRun: () => 'observe',
|
|
representationForRun: () => 'single_run',
|
|
beginFinalizeForRun: vi.fn(),
|
|
finalizeForRun: vi.fn(async () => {
|
|
observed = true;
|
|
return { action: 'observed' };
|
|
}),
|
|
},
|
|
report: legacyReport,
|
|
});
|
|
|
|
observeReporter(
|
|
{ ...terminalMessage, runId: run.id, endedAt: 1234 },
|
|
{ telemetryFinalized: true },
|
|
);
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
expect(observed).toBe(true);
|
|
expect(legacyReport).toHaveBeenCalledOnce();
|
|
expect(run.telemetryDelivery).toMatchObject({
|
|
status: 'accepted',
|
|
finalizedAt: 2,
|
|
});
|
|
|
|
const beginFinalizeForRun = vi.fn(() => ({
|
|
durableTaskTruth: observed,
|
|
suppressSingleRun: observed,
|
|
completion: observed
|
|
? Promise.resolve({ action: 'already_finalized' })
|
|
: Promise.resolve(taskNetwork()),
|
|
}));
|
|
const sendReporter = createFinalizedMessageTelemetryReporter({
|
|
design,
|
|
db: 'db',
|
|
dataDir: '/tmp/od-data',
|
|
reportedRuns: new Set<string>(),
|
|
taskObservationRollout: {
|
|
modeForRun: () => 'send',
|
|
representationForRun: () => 'task_accepted',
|
|
beginFinalizeForRun,
|
|
finalizeForRun: vi.fn(async () => ({ action: 'already_finalized' })),
|
|
},
|
|
report: legacyReport,
|
|
});
|
|
|
|
sendReporter(
|
|
{ ...terminalMessage, runId: run.id, endedAt: 1235 },
|
|
{ telemetryFinalized: true },
|
|
);
|
|
await Promise.resolve();
|
|
|
|
expect(taskNetwork).not.toHaveBeenCalled();
|
|
expect(beginFinalizeForRun).toHaveBeenCalledWith(run.id);
|
|
expect(legacyReport).toHaveBeenCalledOnce();
|
|
expect(run.telemetryDelivery).toMatchObject({
|
|
status: 'accepted',
|
|
finalizedAt: 2,
|
|
});
|
|
});
|
|
|
|
it('keeps the single-run compatibility obligation when the task claim fails', async () => {
|
|
const run = {
|
|
id: 'run-task-claim-failed',
|
|
projectId: 'project-1',
|
|
conversationId: 'conv-1',
|
|
assistantMessageId: 'assistant-1',
|
|
status: 'succeeded',
|
|
createdAt: 1,
|
|
updatedAt: 2,
|
|
events: [],
|
|
};
|
|
const beginTelemetryDelivery = vi.fn(() => ({
|
|
version: 1,
|
|
idempotencyKey: 'od-run-telemetry-v1-claim-fallback',
|
|
status: 'in_flight',
|
|
attemptCount: 0,
|
|
crashWindow: true,
|
|
startedAt: 1,
|
|
}));
|
|
const finalizeTelemetryDelivery = vi.fn();
|
|
const report = vi.fn(async () => ({
|
|
langfuse_expected: true,
|
|
langfuse_delivery_status: 'accepted' as const,
|
|
}));
|
|
const reporter = createFinalizedMessageTelemetryReporter({
|
|
design: {
|
|
runs: {
|
|
get: vi.fn(() => run),
|
|
beginTelemetryDelivery,
|
|
finalizeTelemetryDelivery,
|
|
},
|
|
},
|
|
db: 'db',
|
|
dataDir: '/tmp/od-data',
|
|
reportedRuns: new Set<string>(),
|
|
taskObservationRollout: {
|
|
modeForRun: () => 'send',
|
|
representationForRun: () => 'single_run',
|
|
beginFinalizeForRun: () => {
|
|
throw new Error('synthetic task store unavailable');
|
|
},
|
|
finalizeForRun: vi.fn(),
|
|
},
|
|
report,
|
|
});
|
|
|
|
reporter(
|
|
{ ...terminalMessage, runId: run.id, endedAt: 1234 },
|
|
{ telemetryFinalized: true },
|
|
);
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
|
|
expect(report).toHaveBeenCalledOnce();
|
|
expect(finalizeTelemetryDelivery).toHaveBeenCalledWith(run, {
|
|
langfuse_expected: true,
|
|
langfuse_delivery_status: 'accepted',
|
|
});
|
|
expect(finalizeTelemetryDelivery).not.toHaveBeenCalledWith(
|
|
run,
|
|
expect.objectContaining({ langfuse_drop_reason: 'task_hierarchy_rollout' }),
|
|
);
|
|
expect(run.status).toBe('succeeded');
|
|
});
|
|
|
|
it('keeps the single-run compatibility obligation when Task mode lookup fails', async () => {
|
|
const run = {
|
|
id: 'run-task-mode-failed',
|
|
projectId: 'project-1',
|
|
conversationId: 'conv-1',
|
|
assistantMessageId: 'assistant-1',
|
|
status: 'succeeded',
|
|
createdAt: 1,
|
|
updatedAt: 2,
|
|
events: [],
|
|
};
|
|
const beginTelemetryDelivery = vi.fn(() => ({
|
|
version: 1,
|
|
idempotencyKey: 'od-run-telemetry-v1-mode-fallback',
|
|
status: 'in_flight',
|
|
attemptCount: 0,
|
|
crashWindow: true,
|
|
startedAt: 1,
|
|
}));
|
|
const finalizeTelemetryDelivery = vi.fn();
|
|
const beginFinalizeForRun = vi.fn(() => ({
|
|
durableTaskTruth: true,
|
|
suppressSingleRun: true,
|
|
completion: Promise.resolve({ action: 'sent' }),
|
|
}));
|
|
const report = vi.fn(async () => ({
|
|
langfuse_expected: true,
|
|
langfuse_delivery_status: 'accepted' as const,
|
|
}));
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
const reporter = createFinalizedMessageTelemetryReporter({
|
|
design: {
|
|
runs: {
|
|
get: vi.fn(() => run),
|
|
beginTelemetryDelivery,
|
|
finalizeTelemetryDelivery,
|
|
},
|
|
},
|
|
db: 'db',
|
|
dataDir: '/tmp/od-data',
|
|
reportedRuns: new Set<string>(),
|
|
taskObservationRollout: {
|
|
modeForRun: () => {
|
|
throw new Error('synthetic representation insert failure');
|
|
},
|
|
representationForRun: () => 'single_run',
|
|
beginFinalizeForRun,
|
|
finalizeForRun: vi.fn(),
|
|
},
|
|
report,
|
|
});
|
|
|
|
reporter(
|
|
{ ...terminalMessage, runId: run.id, endedAt: 1234 },
|
|
{ telemetryFinalized: true },
|
|
);
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
|
|
expect(warn).toHaveBeenCalledWith(
|
|
'[telemetry] task observation representation failed',
|
|
'Error: synthetic representation insert failure',
|
|
);
|
|
expect(beginFinalizeForRun).not.toHaveBeenCalled();
|
|
expect(report).toHaveBeenCalledOnce();
|
|
expect(finalizeTelemetryDelivery).toHaveBeenCalledWith(run, {
|
|
langfuse_expected: true,
|
|
langfuse_delivery_status: 'accepted',
|
|
});
|
|
});
|
|
|
|
it('resumes single-run delivery in the same boot after a pre-network compatibility release', async () => {
|
|
const run = {
|
|
id: 'run-task-pre-network-release',
|
|
projectId: 'project-1',
|
|
conversationId: 'conv-1',
|
|
assistantMessageId: 'assistant-1',
|
|
status: 'succeeded',
|
|
createdAt: 1,
|
|
updatedAt: 2,
|
|
events: [],
|
|
};
|
|
const earlierRun = { ...run, id: 'run-task-pre-network-release-earlier' };
|
|
const runsById = new Map([
|
|
[run.id, run],
|
|
[earlierRun.id, earlierRun],
|
|
]);
|
|
let representation: 'task_pending' | 'single_run' = 'task_pending';
|
|
const beginTelemetryDelivery = vi.fn(() => ({
|
|
version: 1,
|
|
idempotencyKey: 'od-run-telemetry-v1-release',
|
|
status: 'in_flight',
|
|
attemptCount: 0,
|
|
crashWindow: true,
|
|
startedAt: 1,
|
|
}));
|
|
const finalizeTelemetryDelivery = vi.fn();
|
|
const report = vi.fn(async () => ({
|
|
langfuse_expected: true,
|
|
langfuse_delivery_status: 'accepted' as const,
|
|
}));
|
|
const reporter = createFinalizedMessageTelemetryReporter({
|
|
design: {
|
|
runs: {
|
|
get: vi.fn((runId: string) => runsById.get(runId)),
|
|
beginTelemetryDelivery,
|
|
finalizeTelemetryDelivery,
|
|
},
|
|
},
|
|
db: 'db',
|
|
dataDir: '/tmp/od-data',
|
|
reportedRuns: new Set<string>(),
|
|
taskObservationRollout: {
|
|
modeForRun: () => representation === 'task_pending' ? 'send' : 'off',
|
|
representationForRun: () => representation,
|
|
beginFinalizeForRun: () => ({
|
|
durableTaskTruth: true,
|
|
suppressSingleRun: true,
|
|
completion: Promise.resolve().then(() => {
|
|
representation = 'single_run';
|
|
return { action: 'compatibility' };
|
|
}),
|
|
}),
|
|
finalizeForRun: vi.fn(),
|
|
},
|
|
report,
|
|
});
|
|
|
|
reporter(
|
|
{ ...terminalMessage, runId: earlierRun.id, endedAt: 1233 },
|
|
{ telemetryFinalized: true },
|
|
);
|
|
reporter(
|
|
{ ...terminalMessage, runId: run.id, endedAt: 1234 },
|
|
{ telemetryFinalized: true },
|
|
);
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
|
|
expect(report).toHaveBeenCalledTimes(2);
|
|
expect(beginTelemetryDelivery).toHaveBeenCalledTimes(2);
|
|
expect(finalizeTelemetryDelivery).toHaveBeenCalledWith(earlierRun, {
|
|
langfuse_expected: true,
|
|
langfuse_delivery_status: 'accepted',
|
|
});
|
|
expect(finalizeTelemetryDelivery).toHaveBeenCalledWith(run, {
|
|
langfuse_expected: true,
|
|
langfuse_delivery_status: 'accepted',
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
label: 'not_expected',
|
|
delivery: {
|
|
langfuse_expected: false,
|
|
langfuse_delivery_status: 'not_expected' as const,
|
|
langfuse_drop_reason: 'content_consent_off' as const,
|
|
},
|
|
},
|
|
{
|
|
label: 'failed',
|
|
delivery: {
|
|
langfuse_expected: true,
|
|
langfuse_delivery_status: 'failed' as const,
|
|
langfuse_drop_reason: 'network_error' as const,
|
|
},
|
|
},
|
|
])('finalizes a terminal $label delivery without changing the run outcome', async ({ delivery }) => {
|
|
const run = {
|
|
id: `run-${delivery.langfuse_delivery_status}`,
|
|
projectId: 'project-1',
|
|
conversationId: 'conv-1',
|
|
assistantMessageId: 'assistant-1',
|
|
status: 'succeeded',
|
|
createdAt: 1,
|
|
updatedAt: 2,
|
|
events: [],
|
|
};
|
|
const beginTelemetryDelivery = vi.fn();
|
|
const finalizeTelemetryDelivery = vi.fn();
|
|
const report = vi.fn(async () => delivery);
|
|
const reporter = createFinalizedMessageTelemetryReporter({
|
|
design: {
|
|
runs: {
|
|
get: vi.fn(() => run),
|
|
beginTelemetryDelivery,
|
|
finalizeTelemetryDelivery,
|
|
},
|
|
},
|
|
db: 'db',
|
|
dataDir: '/tmp/od-data',
|
|
reportedRuns: new Set<string>(),
|
|
getAppVersion: () => ({ version: '0.7.0', channel: 'beta', packaged: true }),
|
|
report,
|
|
});
|
|
|
|
reporter(
|
|
{ ...terminalMessage, runId: run.id, endedAt: 1234 },
|
|
{ telemetryFinalized: true },
|
|
);
|
|
await Promise.resolve();
|
|
|
|
expect(beginTelemetryDelivery).toHaveBeenCalledWith(run);
|
|
expect(finalizeTelemetryDelivery).toHaveBeenCalledWith(run, delivery);
|
|
expect(run.status).toBe('succeeded');
|
|
});
|
|
|
|
it('turns an unexpected reporter rejection into a terminal non-blocking failure', async () => {
|
|
const run = {
|
|
id: 'run-unexpected-rejection',
|
|
projectId: 'project-1',
|
|
conversationId: 'conv-1',
|
|
assistantMessageId: 'assistant-1',
|
|
status: 'succeeded',
|
|
createdAt: 1,
|
|
updatedAt: 2,
|
|
events: [],
|
|
};
|
|
const finalizeTelemetryDelivery = vi.fn();
|
|
const reporter = createFinalizedMessageTelemetryReporter({
|
|
design: {
|
|
runs: {
|
|
get: vi.fn(() => run),
|
|
beginTelemetryDelivery: vi.fn(() => ({
|
|
version: 1,
|
|
idempotencyKey: 'od-run-telemetry-v1-rejection',
|
|
status: 'in_flight',
|
|
attemptCount: 1,
|
|
crashWindow: true,
|
|
startedAt: 1,
|
|
})),
|
|
finalizeTelemetryDelivery,
|
|
},
|
|
},
|
|
db: 'db',
|
|
dataDir: '/tmp/od-data',
|
|
reportedRuns: new Set<string>(),
|
|
getAppVersion: () => ({ version: '0.7.0', channel: 'beta', packaged: true }),
|
|
report: vi.fn(async () => {
|
|
throw new Error('injected reporter rejection');
|
|
}),
|
|
});
|
|
|
|
reporter(
|
|
{ ...terminalMessage, runId: run.id, endedAt: 1234 },
|
|
{ telemetryFinalized: true },
|
|
);
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
|
|
expect(finalizeTelemetryDelivery).toHaveBeenCalledWith(run, {
|
|
langfuse_expected: true,
|
|
langfuse_delivery_status: 'failed',
|
|
langfuse_drop_reason: 'network_error',
|
|
langfuse_attempt_count: 0,
|
|
langfuse_idempotency_key: 'od-run-telemetry-v1-rejection',
|
|
});
|
|
expect(run.status).toBe('succeeded');
|
|
});
|
|
|
|
it('falls back to the run analytics context when final message headers are missing', async () => {
|
|
const run = {
|
|
id: 'run-accepted-headerless-finalize',
|
|
projectId: 'project-1',
|
|
conversationId: 'conv-1',
|
|
assistantMessageId: 'assistant-1',
|
|
agentId: 'codex',
|
|
model: 'default',
|
|
status: 'succeeded',
|
|
createdAt: 1,
|
|
updatedAt: 2,
|
|
events: [],
|
|
analyticsContext: {
|
|
deviceId: 'device-from-run',
|
|
sessionId: 'session-from-run',
|
|
clientType: 'desktop',
|
|
locale: 'zh-CN',
|
|
requestId: 'request-from-run',
|
|
},
|
|
};
|
|
const capture = vi.fn();
|
|
const report = vi.fn(async () => ({
|
|
langfuse_expected: true,
|
|
langfuse_delivery_status: 'accepted' as const,
|
|
}));
|
|
const reporter = createFinalizedMessageTelemetryReporter({
|
|
design: {
|
|
analytics: { capture },
|
|
getAppVersion: () => '0.7.0',
|
|
runs: { get: vi.fn(() => run) },
|
|
},
|
|
db: 'db',
|
|
dataDir: '/tmp/od-data',
|
|
reportedRuns: new Set<string>(),
|
|
getAppVersion: () => ({ version: '0.7.0', channel: 'beta', packaged: true }),
|
|
report,
|
|
});
|
|
|
|
reporter(
|
|
{ ...terminalMessage, runId: run.id, endedAt: 1234 },
|
|
{ telemetryFinalized: true },
|
|
{
|
|
projectId: 'project-1',
|
|
conversationId: 'conv-1',
|
|
},
|
|
);
|
|
await Promise.resolve();
|
|
|
|
expect(capture).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
eventName: 'langfuse_report_result',
|
|
context: run.analyticsContext,
|
|
insertId: 'run-accepted-headerless-finalize-langfuse-report-final_message-accepted',
|
|
properties: expect.objectContaining({
|
|
run_id: 'run-accepted-headerless-finalize',
|
|
langfuse_delivery_status: 'accepted',
|
|
langfuse_report_result: 'accepted',
|
|
langfuse_report_trigger: 'final_message',
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('captures skipped Langfuse reporting when a finalized message references a missing run', () => {
|
|
const capture = vi.fn();
|
|
const reporter = createFinalizedMessageTelemetryReporter({
|
|
design: {
|
|
analytics: { capture },
|
|
getAppVersion: () => '0.7.0',
|
|
runs: { get: vi.fn(() => undefined) },
|
|
},
|
|
db: 'db',
|
|
dataDir: '/tmp/od-data',
|
|
reportedRuns: new Set<string>(),
|
|
getAppVersion: () => ({ version: '0.7.0', channel: 'beta', packaged: true }),
|
|
report: vi.fn(),
|
|
});
|
|
|
|
reporter(
|
|
{ ...terminalMessage, runId: 'run-missing', endedAt: 1234 },
|
|
{ telemetryFinalized: true },
|
|
{
|
|
analyticsContext: {
|
|
deviceId: 'device-1',
|
|
sessionId: 'session-1',
|
|
clientType: 'desktop',
|
|
locale: 'zh-CN',
|
|
requestId: 'request-1',
|
|
},
|
|
projectId: 'project-1',
|
|
conversationId: 'conv-1',
|
|
},
|
|
);
|
|
|
|
expect(capture).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
eventName: 'langfuse_report_result',
|
|
insertId: 'run-missing-langfuse-report-final_message-skipped-run_not_found',
|
|
properties: expect.objectContaining({
|
|
project_id: 'project-1',
|
|
conversation_id: 'conv-1',
|
|
run_id: 'run-missing',
|
|
langfuse_report_result: 'skipped',
|
|
langfuse_report_skip_reason: 'run_not_found',
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
});
|