From d23a4f64726d7fc59d29527e6e90304e6138d6cd Mon Sep 17 00:00:00 2001 From: somew1nd Date: Thu, 3 Sep 2026 14:38:02 -0700 Subject: [PATCH] feat: handle mid-stream LLM resets and deduplicate UI action stream events - Backend (LLM Gateway): - Record structured 'llm_stream_reset' event with discard action, failure reason, and retry messaging on mid-stream generation errors. - Add unit test verifying stream reset event payload on mid-stream errors. - Frontend (Showcase UI): - Handle 'llm_stream_reset' events in agent service and stream aggregator, discarding interrupted chunks while showing retry indicators. - Establish a single source of truth for tool and action events in the timeline to eliminate duplicated action rows while retaining ADB and explorer tools. - Add @angular/cdk dependency and update agent stream component styling and layout. - Add unit tests for stream aggregation and tool display filtering. --- apps/showcase_ui/package-lock.json | 388 +--- apps/showcase_ui/package.json | 1 + .../agent-stream/agent-stream.component.html | 154 +- .../agent-stream/agent-stream.component.scss | 1699 +++++++++-------- .../agent-stream/agent-stream.component.ts | 346 +++- .../src/app/core/models/stream.model.ts | 28 + .../src/app/services/agent.service.ts | 82 +- .../src/app/utils/action-formatter.util.ts | 101 +- .../app/utils/stream-aggregator.util.spec.ts | 136 ++ .../src/app/utils/stream-aggregator.util.ts | 113 +- .../src/app/utils/tool-formatter.util.spec.ts | 38 +- .../src/app/utils/tool-formatter.util.ts | 15 +- apps/showcase_ui/src/styles.scss | 5 + artemis/services/llm.py | 5 + tests/unit/test_llm_gateway.py | 36 + 15 files changed, 1862 insertions(+), 1285 deletions(-) diff --git a/apps/showcase_ui/package-lock.json b/apps/showcase_ui/package-lock.json index 3620aaf..b47bcff 100644 --- a/apps/showcase_ui/package-lock.json +++ b/apps/showcase_ui/package-lock.json @@ -8,6 +8,7 @@ "name": "artemis-showcase-ui", "version": "1.0.0", "dependencies": { + "@angular/cdk": "^22.1.5", "@angular/common": "^22.1.4", "@angular/compiler": "^22.1.4", "@angular/core": "^22.1.4", @@ -221,6 +222,23 @@ } } }, + "node_modules/@angular/cdk": { + "version": "22.1.5", + "resolved": "http://airlock-proxy.uplink.goog:999/npm/artifact-foundry-prod/ah-3p-staging-npm/@angular/cdk/-/cdk-22.1.5.tgz", + "integrity": "sha512-1tYAeI5KKIqNGNvyDuMM0Drs2FU3M4B25Q1BlAnwtZe3wvrpVETAGXgHaBX3Hs/pvWfgMNwgbcHkxlKVUBNXMA==", + "license": "MIT", + "dependencies": { + "parse5": "^8.0.0", + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/common": "^22.0.0 || ^23.0.0", + "@angular/core": "^22.0.0 || ^23.0.0", + "@angular/forms": "^22.0.0 || ^23.0.0", + "@angular/platform-browser": "^22.0.0 || ^23.0.0", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, "node_modules/@angular/cli": { "version": "22.1.6", "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-22.1.6.tgz", @@ -2127,19 +2145,6 @@ "node": ">=6.0.0" } }, - "node_modules/@jridgewell/source-map": { - "version": "0.3.11", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", - "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25" - } - }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -3801,21 +3806,6 @@ "node": ">= 0.6" } }, - "node_modules/acorn": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", - "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/agent-base": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz", @@ -4361,15 +4351,6 @@ "dev": true, "license": "MIT" }, - "node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -4461,24 +4442,6 @@ "node": ">=6.6.0" } }, - "node_modules/copy-anything": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-3.0.5.tgz", - "integrity": "sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "is-what": "^4.1.8" - }, - "engines": { - "node": ">=12.13" - }, - "funding": { - "url": "https://github.com/sponsors/mesqueeb" - } - }, "node_modules/cors": { "version": "2.8.6", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", @@ -4833,21 +4796,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/errno": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/errno/-/errno-0.1.8.tgz", - "integrity": "sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "prr": "~1.0.1" - }, - "bin": { - "errno": "cli.js" - } - }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -5940,21 +5888,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-what": { - "version": "4.1.16", - "resolved": "https://registry.npmjs.org/is-what/-/is-what-4.1.16.tgz", - "integrity": "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=12.13" - }, - "funding": { - "url": "https://github.com/sponsors/mesqueeb" - } - }, "node_modules/isbinaryfile": { "version": "4.0.10", "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", @@ -6275,18 +6208,6 @@ "dev": true, "license": "MIT" }, - "node_modules/jiti": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", - "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "bin": { - "jiti": "lib/jiti-cli.mjs" - } - }, "node_modules/jose": { "version": "6.2.10", "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.10.tgz", @@ -6870,76 +6791,6 @@ "node": ">=10" } }, - "node_modules/less": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/less/-/less-4.9.0.tgz", - "integrity": "sha512-umRhrCH7fCi8Uj2RcwKjJdvUORTjeWqkdKx0LbcZvjIwsAVsnIAGcxHaqowPeBFBjQuWOeC/bve0AlpFzF/+SQ==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "copy-anything": "^3.0.5", - "parse-node-version": "^1.0.1" - }, - "bin": { - "lessc": "bin/lessc" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "errno": "^0.1.1", - "graceful-fs": "^4.1.2", - "make-dir": "^5.1.0", - "mime": "^1.4.1", - "needle": "^3.1.0", - "probe-image-size": "^7.2.3", - "source-map": "~0.6.0" - } - }, - "node_modules/less/node_modules/make-dir": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-5.1.0.tgz", - "integrity": "sha512-IfpFq6UM39dUNiphpA6uDezNx/AvWyhwfICWPR3t1VspkgkMZrL+Rk1RbN1bx+aeNYwOrqGJgEgV3yotk+ZUVw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/less/node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/less/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", - "optional": true, - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/lightningcss": { "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", @@ -7252,15 +7103,6 @@ "dev": true, "license": "MIT" }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/log-symbols": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz", @@ -7567,40 +7409,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/needle": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/needle/-/needle-3.5.0.tgz", - "integrity": "sha512-jaQyPKKk2YokHrEg+vFDYxXIHTCBgiZwSHOoVx/8V3GIBS8/VN6NdVRmg8q1ERtPkMvmOvebsgga4sAj5hls/w==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "iconv-lite": "^0.6.3", - "sax": "^1.2.4" - }, - "bin": { - "needle": "bin/needle" - }, - "engines": { - "node": ">= 4.4.x" - } - }, - "node_modules/needle/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/negotiator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", @@ -7867,23 +7675,10 @@ "@oxc-parser/binding-win32-x64-msvc": "0.142.0" } }, - "node_modules/parse-node-version": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parse-node-version/-/parse-node-version-1.0.1.tgz", - "integrity": "sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/parse5": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", - "dev": true, "license": "MIT", "dependencies": { "entities": "^8.0.0" @@ -7937,7 +7732,6 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=20.19.0" @@ -8093,77 +7887,6 @@ "postcss": "^8.4.31" } }, - "node_modules/probe-image-size": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/probe-image-size/-/probe-image-size-7.4.0.tgz", - "integrity": "sha512-cdEprVtZxV+awMde9X+4jILBFYh4CARxVrQaMl4wY4YcPWbul9jntXrIW95NInBDyJwcVUP3U0T6yukN8rMBaQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/nodeca" - } - ], - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "lodash.merge": "^4.6.2", - "needle": "^2.5.2", - "stream-parser": "~0.3.1" - } - }, - "node_modules/probe-image-size/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/probe-image-size/node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/probe-image-size/node_modules/needle": { - "version": "2.9.1", - "resolved": "https://registry.npmjs.org/needle/-/needle-2.9.1.tgz", - "integrity": "sha512-6R9fqJ5Zcmf+uYaFgdIHmLwNldn5HbK8L5ybn7Uz+ylX/rnOsSp1AHcvQSrCaFN+qNM1wpymHqD7mVasEOlHGQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "debug": "^3.2.6", - "iconv-lite": "^0.4.4", - "sax": "^1.2.4" - }, - "bin": { - "needle": "bin/needle" - }, - "engines": { - "node": ">= 4.4.x" - } - }, "node_modules/proc-log": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-7.0.0.tgz", @@ -8206,15 +7929,6 @@ } } }, - "node_modules/prr": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", - "integrity": "sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/punycode": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", @@ -8497,18 +8211,6 @@ "@parcel/watcher": "^2.4.1" } }, - "node_modules/sax": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", - "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", - "dev": true, - "license": "BlueOak-1.0.0", - "optional": true, - "peer": true, - "engines": { - "node": ">=11.0.0" - } - }, "node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", @@ -8883,39 +8585,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/stream-parser": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/stream-parser/-/stream-parser-0.3.1.tgz", - "integrity": "sha512-bJ/HgKq41nlKvlhccD5kaCr/P+Hu0wPNKPJOH7en+YrJu/9EgqUF+88w5Jb6KNcjOFMhfX4B2asfeAtIGuHObQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "debug": "2" - } - }, - "node_modules/stream-parser/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/stream-parser/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/streamroller": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/streamroller/-/streamroller-3.1.5.tgz", @@ -8978,27 +8647,6 @@ "node": ">=8" } }, - "node_modules/terser": { - "version": "5.49.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.49.0.tgz", - "integrity": "sha512-SNiDnXyHSrxVcIOtVbULzcTmniUiwcV7Nwdyj1twVubeTmbjoa8p69KKDpfkdoOavuM4/GRm1+ykI8qqnavHoA==", - "dev": true, - "license": "BSD-2-Clause", - "optional": true, - "peer": true, - "dependencies": { - "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.15.0", - "commander": "^2.20.0", - "source-map-support": "~0.5.20" - }, - "bin": { - "terser": "bin/terser" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", diff --git a/apps/showcase_ui/package.json b/apps/showcase_ui/package.json index 5781bff..5969a2d 100644 --- a/apps/showcase_ui/package.json +++ b/apps/showcase_ui/package.json @@ -10,6 +10,7 @@ }, "private": true, "dependencies": { + "@angular/cdk": "^22.1.5", "@angular/common": "^22.1.4", "@angular/compiler": "^22.1.4", "@angular/core": "^22.1.4", diff --git a/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.html b/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.html index b58e27d..40dd421 100644 --- a/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.html +++ b/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.html @@ -19,6 +19,8 @@ limitations under the License.
@@ -558,62 +578,109 @@ limitations under the License. @if (isCheckerBlock(block) && item.type === 'thinking') {
- - Thought + + {{ isCheckerSegmentResetNotice(item) ? getCheckerSegmentResetMessage(item) : 'Thought' }}
-
-
-
+ @if (item.data.text && item.data.text.trim()) { +
+
+
+
-
+ }
} @if (isCheckerBlock(block) && item.type === 'text' && isCheckerTextVisible(item)) { -
-
- - Work + @if (item.data.isReset) { +
+
+ +
+ {{ formatResetMessage(getCheckerSegmentResetMessage(item), isCheckerSegmentRunning(block, itemIdx)) }} +
+
-
-
+ } + @if (item.data.text && item.data.text.trim()) { +
+
+ + Work +
+
+
+
-
+ } } @if (!isCheckerBlock(block) && item.type === 'thinking') { -
-
- - Thought + @if (getBlockResets(block, true).length > 0) { +
+ @for (reset of getBlockResets(block, true); track reset.id || $index) { +
+ +
+ {{ formatResetMessage(reset.message, reset.isWaiting) }} +
+
+ }
-
-
-
+ } + @if (getStreamDisplayText(block.id + '-native', item.data.text) || (isBlockRunning(block) && !isStreamRewinding(block.id + '-native') && !hasWaitingReset(block, true))) { +
+
+ + Thought
+ @if (getStreamDisplayText(block.id + '-native', item.data.text)) { +
+
+
+
+
+ }
-
+ } } - @if (!isCheckerBlock(block) && item.type === 'text' && getRawThinking(block)) { -
-
- - Work + @if (!isCheckerBlock(block) && item.type === 'text') { + @if (getBlockResets(block, false).length > 0) { +
+ @for (reset of getBlockResets(block, false); track reset.id || $index) { +
+ +
+ {{ formatResetMessage(reset.message, reset.isWaiting) }} +
+
+ }
-
-
+ } + @if (getStreamDisplayText(block.id, item.data.text) || (isBlockRunning(block) && !isStreamRewinding(block.id) && !hasWaitingReset(block, false))) { +
+
+ + Work +
+ @if (getStreamDisplayText(block.id, item.data.text)) { +
+
+
+ }
-
+ } } @if (isReportStatusAction(item.data)) { @@ -689,7 +756,7 @@ limitations under the License.
} - @if (item.type === 'action' && isAndroidAction(item.data)) { + @if (item.type === 'action' && !isReportStatusAction(item.data) && (isAndroidAction(item.data) || item.data?.type === 'action')) {
{{ getToolIcon(item.data) }} {{ getToolTitle(item.data) }} - @if (getToolAgentName(item.data)) { - {{ getToolAgentName(item.data) }} - } @if (isToolFailed(item.data)) { error diff --git a/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.scss b/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.scss index 54372bb..4fde078 100644 --- a/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.scss +++ b/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.scss @@ -206,818 +206,7 @@ $stream-row-height: 25px; } } - /* Floating Dropdown Popover Menu */ - .vscode-dropdown-menu { - position: absolute; - top: 48px; - right: 12px; - left: auto; - width: 360px; - max-width: calc(100vw - 24px); - max-height: 480px; - background: #ffffff; - border: 1px solid #cbd5e1; - border-radius: 12px; - box-shadow: 0 12px 32px rgba(0, 0, 0, 0.14), 0 4px 12px rgba(0, 0, 0, 0.05); - z-index: 100; - overflow-y: auto; - padding: 14px; - display: flex; - flex-direction: column; - gap: 10px; - animation: dropdownFadeIn 0.15s cubic-bezier(0.16, 1, 0.3, 1); - .popover-title-header { - display: flex; - align-items: center; - gap: 8px; - padding-bottom: 8px; - border-bottom: 1px solid #e2e8f0; - font-size: 14px; - font-weight: 700; - color: #0f172a; - - .material-symbols-outlined { - font-size: 18px; - color: #2563eb; - } - } - - &.notes-dropdown { - right: 12px; - left: auto; - width: 380px; - max-height: 520px; - - .notes-selector-container { - display: flex; - flex-direction: column; - gap: 6px; - margin-bottom: 4px; - - .notes-selector-list { - display: flex; - flex-wrap: wrap; - gap: 6px; - - .note-selector-pill { - display: inline-flex; - align-items: center; - gap: 5px; - padding: 4px 10px; - border: 1px solid #e2e8f0; - background: #ffffff; - border-radius: 16px; - font-size: 11.5px; - font-weight: 500; - color: #475569; - cursor: pointer; - transition: all 0.15s ease; - - .pill-icon { - font-size: 14px; - } - - &:hover { - background: #f8fafc; - border-color: #cbd5e1; - color: #1e293b; - } - - &.active { - background: #eff6ff; - border-color: #bfdbfe; - color: #1d4ed8; - font-weight: 600; - } - } - } - } - - .dropdown-notes-content { - display: flex; - flex-direction: column; - gap: 10px; - padding: 4px 2px; - - .note-title { - font-size: 13.5px; - font-weight: 700; - color: #1f1f1f; - margin: 0 0 4px 0; - font-family: 'Google Sans', 'Google Sans Text', 'Inter', sans-serif; - } - - .code-segment { - font-family: 'Fira Code', 'Roboto Mono', Consolas, monospace; - background-color: #f1f3f4; - color: #1967d2; - padding: 1px 5px; - border-radius: 4px; - font-size: 12px; - margin: 0 2px; - word-break: break-all; - } - - .milestones-container { - display: flex; - flex-direction: column; - gap: 8px; - } - - .milestone-block { - display: flex; - flex-direction: column; - - &.checked { - .milestone-title-row { - .milestone-status-icon.checked { - opacity: 0.7; - } - .milestone-text { - color: #a1a1aa; - } - } - } - - .milestone-title-row { - display: flex; - align-items: flex-start; - gap: 8px; - - .milestone-status-icon { - font-size: 18px; - user-select: none; - margin-top: 2px; - font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 24; - - &.checked, &.progress { - color: #1a73e8; - } - &.unchecked { - color: #cbd5e1; - } - } - - .milestone-text { - font-family: 'Google Sans', 'Google Sans Text', 'Inter', sans-serif; - color: #1f1f1f; - font-size: 13px; - line-height: 1.5; - word-break: break-word; - flex: 1; - } - } - - .milestone-substeps { - margin-top: 4px; - display: flex; - flex-direction: column; - gap: 5px; - padding-left: 26px; - - .substep-row { - display: flex; - align-items: flex-start; - gap: 8px; - font-family: 'Google Sans', 'Google Sans Text', 'Inter', sans-serif; - color: #1f1f1f; - font-size: 12.5px; - line-height: 1.5; - - &.checked { - color: #a1a1aa; - .check-icon.checked { - opacity: 0.7; - } - } - - &.verify { - color: #334155; - gap: 8px; - align-items: flex-start; - - .verify-badge { - flex-shrink: 0; - display: inline-flex; - align-items: center; - justify-content: center; - font-family: 'Google Sans Text', 'Inter', -apple-system, sans-serif; - font-size: 10.5px; - font-weight: 550; - line-height: 1.2; - letter-spacing: 0.2px; - text-transform: lowercase; - color: #475569; - background: #f1f5f9; - border: 1px solid #e2e8f0; - border-radius: 4px; - padding: 1.5px 6px; - margin-top: 1.5px; - user-select: none; - - &.assert { - color: #0369a1; - background: #f0f9ff; - border-color: #e0f2fe; - } - } - - .line-text { - color: #334155; - font-size: 12.5px; - line-height: 1.5; - } - } - - &.finding { - color: #92400e; - gap: 8px; - align-items: flex-start; - - .finding-badge { - flex-shrink: 0; - display: inline-flex; - align-items: center; - justify-content: center; - font-family: 'Google Sans Text', 'Inter', -apple-system, sans-serif; - font-size: 10.5px; - font-weight: 550; - line-height: 1.2; - letter-spacing: 0.2px; - text-transform: lowercase; - color: #b45309; - background: #fef3c7; - border: 1px solid #fde68a; - border-radius: 4px; - padding: 1.5px 6px; - margin-top: 1.5px; - user-select: none; - } - - .line-text { - color: #92400e; - font-size: 12.5px; - line-height: 1.5; - } - } - - .check-icon { - font-size: 16px; - user-select: none; - margin-top: 2px; - font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 24; - - &.checked, &.progress { - color: #1a73e8; - } - &.unchecked { - color: #cbd5e1; - } - } - - .bullet { - color: #9ca3af; - font-weight: bold; - user-select: none; - } - - .line-text { - word-break: break-word; - color: inherit; - } - } - } - - .milestone-checks-container { - margin-top: 6px; - padding-left: 26px; - display: flex; - flex-direction: column; - gap: 6px; - } - } - - .verification-card { - background: #f8fafc; - border: 1px solid #e2e8f0; - border-radius: 6px; - padding: 7px 10px; - display: flex; - flex-direction: column; - gap: 3px; - - &.standalone { - margin-top: 6px; - margin-bottom: 6px; - } - - .card-header { - display: flex; - align-items: center; - - .card-tag { - font-family: 'Google Sans Text', 'Inter', -apple-system, sans-serif; - font-size: 9.5px; - font-weight: 600; - line-height: 1; - letter-spacing: 0.6px; - text-transform: uppercase; - color: #64748b; - - &.assert { - color: #0284c7; - } - } - } - - .card-body { - font-family: inherit; - font-size: 12px; - line-height: 1.5; - color: #334155; - word-break: break-word; - - .bold-segment { - font-weight: 600; - color: #1e293b; - } - - .code-segment { - font-family: 'Fira Code', 'Roboto Mono', Consolas, monospace; - font-size: 11px; - background: #f1f5f9; - color: #0f172a; - padding: 1px 5px; - border-radius: 4px; - border: 1px solid #e2e8f0; - } - } - - &.finding { - background: #fffbeb; - border-color: #fef3c7; - - .card-header .card-tag { - color: #b45309; - } - - .card-body { - color: #92400e; - } - } - } - - .other-lines-container { - display: flex; - flex-direction: column; - gap: 6px; - margin-top: 6px; - padding-top: 6px; - border-top: 1px dashed #e2e8f0; - - .md-line { - display: flex; - align-items: flex-start; - gap: 6px; - font-size: 12.5px; - color: #334155; - line-height: 1.5; - - .line-text { - word-break: break-word; - color: inherit; - } - } - } - } - } - - &::-webkit-scrollbar { - width: 6px; - } - &::-webkit-scrollbar-thumb { - background: #cbd5e1; - border-radius: 4px; - } - - .dropdown-header { - display: flex; - align-items: center; - justify-content: space-between; - padding-bottom: 8px; - border-bottom: 1px solid #e2e8f0; - - .dropdown-title { - display: flex; - align-items: center; - gap: 8px; - font-size: 13px; - font-weight: 700; - color: #0f172a; - - .icon-main { - font-size: 18px; - color: #2563eb; - } - } - } - - .dropdown-section-label { - display: flex; - align-items: center; - gap: 6px; - font-size: 11px; - font-weight: 700; - color: #475569; - text-transform: uppercase; - letter-spacing: 0.5px; - padding: 4px 2px 2px 2px; - - &.history-label { - margin-top: 6px; - } - - .icon-bolt { - font-size: 15px; - color: #d97706; - } - .icon-history { - font-size: 15px; - color: #64748b; - } - } - - .dropdown-empty-state { - padding: 20px 14px; - text-align: center; - font-size: 12px; - color: #94a3b8; - background: #f8fafc; - border: 1px dashed #e2e8f0; - border-radius: 6px; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 6px; - - .empty-icon { - font-size: 22px; - color: #94a3b8; - } - } - - .dropdown-history-list { - display: flex; - flex-direction: column; - gap: 8px; - } - - .dropdown-item-card { - padding: 10px 12px; - background: #f8fafc; - border: 1px solid #e2e8f0; - border-radius: 6px; - cursor: pointer; - transition: all 0.15s ease; - display: flex; - flex-direction: column; - gap: 6px; - - &:hover { - background: #f1f5f9; - border-color: #cbd5e1; - transform: translateY(-1px); - - .item-header .item-header-actions { - &:has(.btn-delete-card) .item-time, - &:has(.btn-delete-card) .item-time-divider, - &:has(.btn-stop-card) .item-time, - &:has(.btn-stop-card) .item-time-divider { - opacity: 0; - } - - .btn-delete-card, - .btn-stop-card { - opacity: 1; - pointer-events: auto; - } - } - } - - &.selected { - background: #eff6ff; - border-color: #93c5fd; - box-shadow: 0 0 0 1px #2563eb; - - .item-goal { - color: #1e3a8a; - font-weight: 600; - } - } - - &.running { - border-left: 3.5px solid #2563eb; - } - - .item-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - min-width: 0; - - .task-badge { - flex-shrink: 0; - display: inline-flex; - align-items: center; - font-size: 10.5px; - font-weight: 700; - padding: 2px 5px; - border-radius: 4px; - letter-spacing: 0.05em; - border: 1px solid #e2e8f0; - background: #fafafa; - color: #64748b; - - &::before { - content: ''; - width: 4px; - height: 4px; - border-radius: 50%; - display: inline-block; - margin-right: 4px; - } - - &.running { - background: #fafafa; - color: #1f1f1f; - border-color: #1f1f1f; - &::before { - background-color: #1f1f1f; - animation: pulse-black-dot-small 1.5s infinite ease-in-out; - } - } - &.paused { - background: #fff7ed; - color: #c2410c; - border-color: #fdba74; - &::before { background-color: #f97316; } - } - &.completed { - background: #fafafa; - color: #1f1f1f; - border-color: #e2e8f0; - &::before { - background-color: #1f1f1f; - } - } - &.pending { - background: #fafafa; - color: #64748b; - border-color: #e2e8f0; - &::before { - background-color: #94a3b8; - } - } - &.failed { - background: #fafafa; - color: #ef4444; - border-color: #fee2e2; - &::before { - background-color: #ef4444; - } - } - &.cancelled { - background: #fafafa; - color: #d97706; - border-color: #fef3c7; - &::before { - background-color: #f59e0b; - } - } - - .status-dot { - display: none; - } - } - - .item-header-actions { - display: flex; - align-items: center; - gap: 4px; - position: relative; - min-width: 0; - flex-shrink: 1; - justify-content: flex-end; - - .item-device { - display: inline-flex; - align-items: center; - gap: 2.5px; - min-width: 0; - flex-shrink: 1; - font-size: 10.5px; - color: #64748b; - line-height: 1; - cursor: default; - transition: color 0.15s ease, opacity 0.15s ease; - - .device-icon { - font-size: 12px; - width: 12px; - height: 12px; - line-height: 12px; - color: #64748b; - opacity: 0.75; - flex-shrink: 0; - } - - .device-name { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - min-width: 0; - font-family: inherit; - letter-spacing: -0.15px; - } - - &:hover { - color: #334155; - .device-icon { - color: #334155; - opacity: 1; - } - } - } - - .item-time-divider { - font-size: 10px; - color: #94a3b8; - line-height: 1; - user-select: none; - flex-shrink: 0; - transition: opacity 0.15s ease; - } - - .item-time { - font-size: 11px; - color: #64748b; - white-space: nowrap; - flex-shrink: 0; - line-height: 1; - transition: opacity 0.15s ease; - } - - .btn-delete-card { - position: absolute; - right: 0; - top: 50%; - transform: translateY(-50%); - width: 18px; - height: 18px; - padding: 0; - border: none; - background: transparent; - border-radius: 3px; - color: #94a3b8; - cursor: pointer; - display: inline-flex; - align-items: center; - justify-content: center; - opacity: 0; - pointer-events: none; - transition: all 0.15s ease; - - .material-symbols-outlined { - font-size: 13px; - line-height: 1; - } - - &:hover { - color: #ef4444; - background: #fee2e2; - } - } - - .btn-stop-card { - position: absolute; - right: 0; - top: 50%; - transform: translateY(-50%); - width: 18px; - height: 18px; - padding: 0; - border: none; - background: transparent; - border-radius: 3px; - color: #ef4444; - cursor: pointer; - display: inline-flex; - align-items: center; - justify-content: center; - opacity: 0; - pointer-events: none; - transition: all 0.15s ease; - - .material-symbols-outlined { - font-size: 14px; - line-height: 1; - } - - &:hover { - color: #dc2626; - background: #fee2e2; - transform: translateY(-50%) scale(1.1); - } - } - } - } - - .item-goal { - font-size: 12.5px; - color: #334155; - line-height: 1.4; - display: -webkit-box; - -webkit-line-clamp: 2; - line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; - } - - .item-footer { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - font-size: 10.5px; - color: #94a3b8; - - .item-id { - font-family: 'Fira Code', 'Roboto Mono', Consolas, monospace; - font-size: 10.5px; - color: #94a3b8; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - min-width: 0; - flex: 1; - transition: color 0.15s ease; - - &:hover { - color: #475569; - } - } - - .item-date { - flex-shrink: 0; - } - } - } - - .dropdown-note-item { - display: flex; - align-items: center; - gap: 10px; - padding: 8px 12px; - border-radius: 6px; - cursor: pointer; - font-size: 13px; - color: #334155; - transition: background 0.15s ease; - - .note-icon { - font-size: 18px; - color: #64748b; - } - - .note-key-text { - flex: 1; - font-weight: 500; - } - - .check-icon { - font-size: 18px; - color: #2563eb; - } - - &:hover { - background: #f1f5f9; - color: #0f172a; - } - - &.selected { - background: #eff6ff; - color: #2563eb; - font-weight: 600; - - .note-icon { - color: #2563eb; - } - } - } - } } .stream-floating-toolbar { @@ -1539,6 +728,24 @@ $stream-row-height: 25px; } } + &.is-resetting { + .details-summary { + background: none !important; + -webkit-background-clip: border-box !important; + background-clip: border-box !important; + -webkit-text-fill-color: #71717a !important; + color: #71717a !important; + animation: none !important; + font-weight: normal !important; + + .stream-status-dot { + background: #f59e0b; + box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.45); + animation: pulse-stream-amber 1.8s cubic-bezier(0.4, 0, 0.6, 1) infinite; + } + } + } + .stream-status-dot { background: #d4d4d8; @@ -1547,6 +754,12 @@ $stream-row-height: 25px; box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.45); animation: pulse-stream-dot 1.8s cubic-bezier(0.4, 0, 0.6, 1) infinite; } + + &.resetting { + background: #f59e0b; + box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.45); + animation: pulse-stream-amber 1.8s cubic-bezier(0.4, 0, 0.6, 1) infinite; + } } .stream-text { @@ -2044,6 +1257,25 @@ $stream-row-height: 25px; } } + &.is-resetting { + .report-header { + background: none !important; + -webkit-background-clip: border-box !important; + background-clip: border-box !important; + -webkit-text-fill-color: #71717a !important; + color: #71717a !important; + animation: none !important; + font-weight: normal !important; + margin-bottom: 0; + + .stream-status-dot { + background: #f59e0b; + box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.45); + animation: pulse-stream-amber 1.8s cubic-bezier(0.4, 0, 0.6, 1) infinite; + } + } + } + .report-header { font-family: 'Google Sans', 'Google Sans Text', 'Inter', Arial, sans-serif; font-weight: normal; @@ -2059,6 +1291,11 @@ $stream-row-height: 25px; align-items: center; gap: 8px; + .header-label { + line-height: 1.5; + word-break: break-word; + } + .stream-status-dot { width: 8px; height: 8px; @@ -2071,6 +1308,12 @@ $stream-row-height: 25px; box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.45); animation: pulse-stream-dot 1.8s cubic-bezier(0.4, 0, 0.6, 1) infinite; } + + &.resetting { + background: #f59e0b; + box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.45); + animation: pulse-stream-amber 1.8s cubic-bezier(0.4, 0, 0.6, 1) infinite; + } } } @@ -2215,6 +1458,12 @@ $stream-row-height: 25px; box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.45); animation: pulse-stream-dot 1.8s cubic-bezier(0.4, 0, 0.6, 1) infinite; } + + &.resetting { + background: #f59e0b; + box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.45); + animation: pulse-stream-amber 1.8s cubic-bezier(0.4, 0, 0.6, 1) infinite; + } } .llm-retry-note-text { @@ -3393,6 +2642,22 @@ $stream-row-height: 25px; } } +@keyframes pulse-stream-amber { + 0%, 100% { + transform: scale(1); + box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.45); + } + 50% { + transform: scale(1.15); + box-shadow: 0 0 0 4px rgba(245, 158, 11, 0); + } +} + +.spinning { + animation: spin 1.2s linear infinite; +} + + @keyframes subtle-card-glow { 0% { border-color: rgba(59, 130, 246, 0.25); @@ -3740,3 +3005,815 @@ $stream-row-height: 25px; } } } + +/* ========================================================================= */ +/* Floating Dropdown Popover Menu (Angular CDK Connected Overlay Top Layer) */ +/* ========================================================================= */ +/* Floating Dropdown Popover Menu */ +.vscode-dropdown-menu { + position: relative; + width: 360px; + box-sizing: border-box; + max-width: calc(100vw - 24px); + max-height: 480px; + background: #ffffff; + border: 1px solid #cbd5e1; + border-radius: 12px; + box-shadow: 0 16px 40px -4px rgba(15, 23, 42, 0.16), 0 4px 12px rgba(0, 0, 0, 0.06); + z-index: 100; + overflow-y: auto; + padding: 14px; + display: flex; + flex-direction: column; + gap: 10px; + animation: dropdownFadeIn 0.15s cubic-bezier(0.16, 1, 0.3, 1); + + .popover-title-header { + display: flex; + align-items: center; + gap: 8px; + padding-bottom: 8px; + border-bottom: 1px solid #e2e8f0; + font-size: 14px; + font-weight: 700; + color: #0f172a; + + .material-symbols-outlined { + font-size: 18px; + color: #2563eb; + } + } + + &.notes-dropdown { + width: 380px; + max-height: 520px; + + .notes-selector-container { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 4px; + + .notes-selector-list { + display: flex; + flex-wrap: wrap; + gap: 6px; + + .note-selector-pill { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 10px; + border: 1px solid #e2e8f0; + background: #ffffff; + border-radius: 16px; + font-size: 11.5px; + font-weight: 500; + color: #475569; + cursor: pointer; + transition: all 0.15s ease; + + .pill-icon { + font-size: 14px; + } + + &:hover { + background: #f8fafc; + border-color: #cbd5e1; + color: #1e293b; + } + + &.active { + background: #eff6ff; + border-color: #bfdbfe; + color: #1d4ed8; + font-weight: 600; + } + } + } + } + + .dropdown-notes-content { + display: flex; + flex-direction: column; + gap: 10px; + padding: 4px 2px; + + .note-title { + font-size: 13.5px; + font-weight: 700; + color: #1f1f1f; + margin: 0 0 4px 0; + font-family: 'Google Sans', 'Google Sans Text', 'Inter', sans-serif; + } + + .code-segment { + font-family: 'Fira Code', 'Roboto Mono', Consolas, monospace; + background-color: #f1f3f4; + color: #1967d2; + padding: 1px 5px; + border-radius: 4px; + font-size: 12px; + margin: 0 2px; + word-break: break-all; + } + + .milestones-container { + display: flex; + flex-direction: column; + gap: 8px; + } + + .milestone-block { + display: flex; + flex-direction: column; + + &.checked { + .milestone-title-row { + .milestone-status-icon.checked { + opacity: 0.7; + } + .milestone-text { + color: #a1a1aa; + } + } + } + + .milestone-title-row { + display: flex; + align-items: flex-start; + gap: 8px; + + .milestone-status-icon { + font-size: 18px; + user-select: none; + margin-top: 2px; + font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 24; + + &.checked, &.progress { + color: #1a73e8; + } + &.unchecked { + color: #cbd5e1; + } + } + + .milestone-text { + font-family: 'Google Sans', 'Google Sans Text', 'Inter', sans-serif; + color: #1f1f1f; + font-size: 13px; + line-height: 1.5; + word-break: break-word; + flex: 1; + } + } + + .milestone-substeps { + margin-top: 4px; + display: flex; + flex-direction: column; + gap: 5px; + padding-left: 26px; + + .substep-row { + display: flex; + align-items: flex-start; + gap: 8px; + font-family: 'Google Sans', 'Google Sans Text', 'Inter', sans-serif; + color: #1f1f1f; + font-size: 12.5px; + line-height: 1.5; + + &.checked { + color: #a1a1aa; + .check-icon.checked { + opacity: 0.7; + } + } + + &.verify { + color: #334155; + gap: 8px; + align-items: flex-start; + + .verify-badge { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + font-family: 'Google Sans Text', 'Inter', -apple-system, sans-serif; + font-size: 10.5px; + font-weight: 550; + line-height: 1.2; + letter-spacing: 0.2px; + text-transform: lowercase; + color: #475569; + background: #f1f5f9; + border: 1px solid #e2e8f0; + border-radius: 4px; + padding: 1.5px 6px; + margin-top: 1.5px; + user-select: none; + + &.assert { + color: #0369a1; + background: #f0f9ff; + border-color: #e0f2fe; + } + } + + .line-text { + color: #334155; + font-size: 12.5px; + line-height: 1.5; + } + } + + &.finding { + color: #92400e; + gap: 8px; + align-items: flex-start; + + .finding-badge { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + font-family: 'Google Sans Text', 'Inter', -apple-system, sans-serif; + font-size: 10.5px; + font-weight: 550; + line-height: 1.2; + letter-spacing: 0.2px; + text-transform: lowercase; + color: #b45309; + background: #fef3c7; + border: 1px solid #fde68a; + border-radius: 4px; + padding: 1.5px 6px; + margin-top: 1.5px; + user-select: none; + } + + .line-text { + color: #92400e; + font-size: 12.5px; + line-height: 1.5; + } + } + + .check-icon { + font-size: 16px; + user-select: none; + margin-top: 2px; + font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 24; + + &.checked, &.progress { + color: #1a73e8; + } + &.unchecked { + color: #cbd5e1; + } + } + + .bullet { + color: #9ca3af; + font-weight: bold; + user-select: none; + } + + .line-text { + word-break: break-word; + color: inherit; + } + } + } + + .milestone-checks-container { + margin-top: 6px; + padding-left: 26px; + display: flex; + flex-direction: column; + gap: 6px; + } + } + + .verification-card { + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 6px; + padding: 7px 10px; + display: flex; + flex-direction: column; + gap: 3px; + + &.standalone { + margin-top: 6px; + margin-bottom: 6px; + } + + .card-header { + display: flex; + align-items: center; + + .card-tag { + font-family: 'Google Sans Text', 'Inter', -apple-system, sans-serif; + font-size: 9.5px; + font-weight: 600; + line-height: 1; + letter-spacing: 0.6px; + text-transform: uppercase; + color: #64748b; + + &.assert { + color: #0284c7; + } + } + } + + .card-body { + font-family: inherit; + font-size: 12px; + line-height: 1.5; + color: #334155; + word-break: break-word; + + .bold-segment { + font-weight: 600; + color: #1e293b; + } + + .code-segment { + font-family: 'Fira Code', 'Roboto Mono', Consolas, monospace; + font-size: 11px; + background: #f1f5f9; + color: #0f172a; + padding: 1px 5px; + border-radius: 4px; + border: 1px solid #e2e8f0; + } + } + + &.finding { + background: #fffbeb; + border-color: #fef3c7; + + .card-header .card-tag { + color: #b45309; + } + + .card-body { + color: #92400e; + } + } + } + + .other-lines-container { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 6px; + padding-top: 6px; + border-top: 1px dashed #e2e8f0; + + .md-line { + display: flex; + align-items: flex-start; + gap: 6px; + font-size: 12.5px; + color: #334155; + line-height: 1.5; + + .line-text { + word-break: break-word; + color: inherit; + } + } + } + } + } + + &::-webkit-scrollbar { + width: 6px; + } + &::-webkit-scrollbar-thumb { + background: #cbd5e1; + border-radius: 4px; + } + + .dropdown-header { + display: flex; + align-items: center; + justify-content: space-between; + padding-bottom: 8px; + border-bottom: 1px solid #e2e8f0; + + .dropdown-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + font-weight: 700; + color: #0f172a; + + .icon-main { + font-size: 18px; + color: #2563eb; + } + } + } + + .dropdown-section-label { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + font-weight: 700; + color: #475569; + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 4px 2px 2px 2px; + + &.history-label { + margin-top: 6px; + } + + .icon-bolt { + font-size: 15px; + color: #d97706; + } + .icon-history { + font-size: 15px; + color: #64748b; + } + } + + .dropdown-empty-state { + padding: 20px 14px; + text-align: center; + font-size: 12px; + color: #94a3b8; + background: #f8fafc; + border: 1px dashed #e2e8f0; + border-radius: 6px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + + .empty-icon { + font-size: 22px; + color: #94a3b8; + } + } + + .dropdown-history-list { + display: flex; + flex-direction: column; + gap: 8px; + } + + .dropdown-item-card { + padding: 10px 12px; + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 6px; + cursor: pointer; + transition: all 0.15s ease; + display: flex; + flex-direction: column; + gap: 6px; + + &:hover { + background: #f1f5f9; + border-color: #cbd5e1; + transform: translateY(-1px); + + .item-header .item-header-actions { + &:has(.btn-delete-card) .item-time, + &:has(.btn-delete-card) .item-time-divider, + &:has(.btn-stop-card) .item-time, + &:has(.btn-stop-card) .item-time-divider { + opacity: 0; + } + + .btn-delete-card, + .btn-stop-card { + opacity: 1; + pointer-events: auto; + } + } + } + + &.selected { + background: #eff6ff; + border-color: #93c5fd; + box-shadow: 0 0 0 1px #2563eb; + + .item-goal { + color: #1e3a8a; + font-weight: 600; + } + } + + &.running { + border-left: 3.5px solid #2563eb; + } + + .item-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-width: 0; + + .task-badge { + flex-shrink: 0; + display: inline-flex; + align-items: center; + font-size: 10.5px; + font-weight: 700; + padding: 2px 5px; + border-radius: 4px; + letter-spacing: 0.05em; + border: 1px solid #e2e8f0; + background: #fafafa; + color: #64748b; + + &::before { + content: ''; + width: 4px; + height: 4px; + border-radius: 50%; + display: inline-block; + margin-right: 4px; + } + + &.running { + background: #fafafa; + color: #1f1f1f; + border-color: #1f1f1f; + &::before { + background-color: #1f1f1f; + animation: pulse-black-dot-small 1.5s infinite ease-in-out; + } + } + &.paused { + background: #fff7ed; + color: #c2410c; + border-color: #fdba74; + &::before { background-color: #f97316; } + } + &.completed { + background: #fafafa; + color: #1f1f1f; + border-color: #e2e8f0; + &::before { + background-color: #1f1f1f; + } + } + &.pending { + background: #fafafa; + color: #64748b; + border-color: #e2e8f0; + &::before { + background-color: #94a3b8; + } + } + &.failed { + background: #fafafa; + color: #ef4444; + border-color: #fee2e2; + &::before { + background-color: #ef4444; + } + } + &.cancelled { + background: #fafafa; + color: #d97706; + border-color: #fef3c7; + &::before { + background-color: #f59e0b; + } + } + + .status-dot { + display: none; + } + } + + .item-header-actions { + display: flex; + align-items: center; + gap: 4px; + position: relative; + min-width: 0; + flex-shrink: 1; + justify-content: flex-end; + + .item-device { + display: inline-flex; + align-items: center; + gap: 2.5px; + min-width: 0; + flex-shrink: 1; + font-size: 10.5px; + color: #64748b; + line-height: 1; + cursor: default; + transition: color 0.15s ease, opacity 0.15s ease; + + .device-icon { + font-size: 12px; + width: 12px; + height: 12px; + line-height: 12px; + color: #64748b; + opacity: 0.75; + flex-shrink: 0; + } + + .device-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + font-family: inherit; + letter-spacing: -0.15px; + } + + &:hover { + color: #334155; + .device-icon { + color: #334155; + opacity: 1; + } + } + } + + .item-time-divider { + font-size: 10px; + color: #94a3b8; + line-height: 1; + user-select: none; + flex-shrink: 0; + transition: opacity 0.15s ease; + } + + .item-time { + font-size: 11px; + color: #64748b; + white-space: nowrap; + flex-shrink: 0; + line-height: 1; + transition: opacity 0.15s ease; + } + + .btn-delete-card { + position: absolute; + right: 0; + top: 50%; + transform: translateY(-50%); + width: 18px; + height: 18px; + padding: 0; + border: none; + background: transparent; + border-radius: 3px; + color: #94a3b8; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + opacity: 0; + pointer-events: none; + transition: all 0.15s ease; + + .material-symbols-outlined { + font-size: 13px; + line-height: 1; + } + + &:hover { + color: #ef4444; + background: #fee2e2; + } + } + + .btn-stop-card { + position: absolute; + right: 0; + top: 50%; + transform: translateY(-50%); + width: 18px; + height: 18px; + padding: 0; + border: none; + background: transparent; + border-radius: 3px; + color: #ef4444; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + opacity: 0; + pointer-events: none; + transition: all 0.15s ease; + + .material-symbols-outlined { + font-size: 14px; + line-height: 1; + } + + &:hover { + color: #dc2626; + background: #fee2e2; + transform: translateY(-50%) scale(1.1); + } + } + } + } + + .item-goal { + font-size: 12.5px; + color: #334155; + line-height: 1.4; + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + } + + .item-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + font-size: 10.5px; + color: #94a3b8; + + .item-id { + font-family: 'Fira Code', 'Roboto Mono', Consolas, monospace; + font-size: 10.5px; + color: #94a3b8; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; + flex: 1; + transition: color 0.15s ease; + + &:hover { + color: #475569; + } + } + + .item-date { + flex-shrink: 0; + } + } + } + + .dropdown-note-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 12px; + border-radius: 6px; + cursor: pointer; + font-size: 13px; + color: #334155; + transition: background 0.15s ease; + + .note-icon { + font-size: 18px; + color: #64748b; + } + + .note-key-text { + flex: 1; + font-weight: 500; + } + + .check-icon { + font-size: 18px; + color: #2563eb; + } + + &:hover { + background: #f1f5f9; + color: #0f172a; + } + + &.selected { + background: #eff6ff; + color: #2563eb; + font-weight: 600; + + .note-icon { + color: #2563eb; + } + } + } +} diff --git a/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.ts b/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.ts index 2c19fba..5b585b8 100644 --- a/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.ts +++ b/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.ts @@ -22,7 +22,8 @@ import { HttpClient } from '@angular/common/http'; import { AgentService, StartupProgressEvent } from '../../services/agent.service'; import { Session, ModelInfo } from '../../core/models/session.model'; import { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote } from '../../core/models/markdown.model'; -import { StepBlock, PhaseBlock, StepEvent, ActionParam, CheckerResult } from '../../core/models/stream.model'; +import { OverlayModule, ConnectedPosition } from '@angular/cdk/overlay'; +import { StepBlock, PhaseBlock, StepEvent, ActionParam, CheckerResult, StreamResetNotice, DEFAULT_STREAM_RESET_MESSAGE } from '../../core/models/stream.model'; export const PLANNING_LOADER_PHRASES: string[] = [ 'Planning next step...', @@ -195,12 +196,12 @@ import { checkPlanningLoader } from '../../utils/stream-aggregator.util'; -export type { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote }; +export type { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote, StreamResetNotice }; @Component({ selector: 'app-agent-stream', standalone: true, - imports: [CommonModule, FormsModule], + imports: [CommonModule, FormsModule, OverlayModule], templateUrl: './agent-stream.component.html', styleUrl: './agent-stream.component.scss', changeDetection: ChangeDetectionStrategy.OnPush @@ -234,6 +235,31 @@ export class AgentStreamComponent implements AfterViewInit { public isTaskDropdownOpen = signal(false); public isNotesDropdownOpen = signal(false); + // CDK Connected Overlay Positioning Strategy + public readonly dropdownPositions: ConnectedPosition[] = [ + { + originX: 'end', + originY: 'bottom', + overlayX: 'end', + overlayY: 'top', + offsetY: 6, + }, + { + originX: 'end', + originY: 'top', + overlayX: 'end', + overlayY: 'bottom', + offsetY: -6, + }, + { + originX: 'start', + originY: 'bottom', + overlayX: 'start', + overlayY: 'top', + offsetY: 6, + } + ]; + // Zoom Modal State public selectedZoomImage = signal(null); public selectedZoomAction = signal(null); @@ -258,6 +284,18 @@ export class AgentStreamComponent implements AfterViewInit { private static readonly TYPING_CHARS_PER_SECOND = 667; private static readonly TYPING_MIN_FRAME_MS = 28; + // Active reset notices per block (displayed after typewriter rewind finishes) + public activeBlockResets = signal>(new Map()); + public activeResetNotices = signal>(new Map()); + private rewindTargets = new Map(); + // Fast rewind pace (backspace speed ≈ 4x normal typing speed for snappy deletion) + private static readonly REWIND_CHARS_PER_SECOND = 2600; + // Rendered-markdown memo per template slot: returning the identical string // instance for unchanged text lets the [innerHTML] binding skip re-sanitizing // and re-parsing the whole fragment on every change-detection pass. @@ -466,6 +504,18 @@ export class AgentStreamComponent implements AfterViewInit { this.scheduleAutoScroll(false); }); + // Listen for stream reset events to trigger fast typewriter rewind + effect(() => { + const resetEvent = this.agentService.streamResetEvent(); + if (!resetEvent) return; + untracked(() => { + this.triggerStreamRewind( + resetEvent.stream_execution_id || resetEvent.stream_exec_id, + resetEvent.message || DEFAULT_STREAM_RESET_MESSAGE + ); + }); + }); + // Typewriter drive logic: triggers typing when block appears or expands effect(() => { const blocks = this.consolidatedBlocks(); @@ -473,6 +523,39 @@ export class AgentStreamComponent implements AfterViewInit { // Check blocks render their per-turn segments directly (no typewriter). if (block.type === 'checker') return; const blockId = block.id; + + // If the block is flagged as reset, ensure typewriter rewind is active or reset notice is set + if (block.data?.isReset) { + untracked(() => { + const resetMsg = block.data.resetMessage || DEFAULT_STREAM_RESET_MESSAGE; + const currentRecord = this.typedTextsSignal(); + const hasText = (currentRecord[blockId]?.length || 0) > 0 || (currentRecord[blockId + '-native']?.length || 0) > 0; + if (hasText) { + if (!this.rewindTargets.has(blockId) && !this.rewindTargets.has(blockId + '-native')) { + this.triggerStreamRewind(block.data.execution_id, resetMsg); + } + } else { + const activeResets = this.activeBlockResets().get(blockId) || []; + if (activeResets.length === 0) { + this.upsertBlockResetNotice(blockId, { + id: block.data.execution_id || `${blockId}-text`, + message: resetMsg, + isWaiting: !block.data?.isCompleted, + streamType: 'text' + }); + } + if (!this.activeResetNotices().has(blockId)) { + this.activeResetNotices.update(m => { + const next = new Map(m); + next.set(blockId, resetMsg); + return next; + }); + } + } + }); + return; + } + const rawText = this.getRawThinking(block) || ''; const nativeText = this.getNativeThinking(block) || ''; @@ -541,6 +624,8 @@ export class AgentStreamComponent implements AfterViewInit { untracked(() => { this.stopTypingLoop(); this.typedTextsSignal.set({}); + this.activeResetNotices.set(new Map()); + this.activeBlockResets.set(new Map()); this.markdownHtmlCache.clear(); this.scheduledCollapses.clear(); this.collapsedStreams.set(new Set()); @@ -754,8 +839,51 @@ export class AgentStreamComponent implements AfterViewInit { } // Typewriter & Delayed Collapse + private upsertBlockResetNotice(blockId: string, notice: StreamResetNotice): void { + this.activeBlockResets.update(m => { + const next = new Map(m); + const list = [...(next.get(blockId) || [])]; + const existingIdx = list.findIndex(r => r.id === notice.id); + if (existingIdx > -1) { + list[existingIdx] = notice; + } else { + list.push(notice); + } + next.set(blockId, list); + return next; + }); + } + private startTyping(key: string, targetText: string, isThinking: boolean = false, execId?: string) { const blockId = key.endsWith('-native') ? key.replace('-native', '') : key; + // If there was an active rewind, cancel it, reset text to empty, and record its reset notice as completed waiting + const activeRewind = this.rewindTargets.get(key); + if (activeRewind) { + this.rewindTargets.delete(key); + this.typedTextsSignal.update(r => ({ ...r, [key]: '' })); + this.upsertBlockResetNotice(blockId, { + id: activeRewind.execId || `${blockId}-${isThinking ? 'thinking' : 'text'}`, + message: activeRewind.message, + isWaiting: false, + streamType: isThinking ? 'thinking' : 'text' + }); + } + + // Cancel any active rewind on this key + this.rewindTargets.delete(key); + + // When retry stream starts outputting content, change waiting dot to normal dot (isWaiting: false) + // The previous prompt sentence DOES NOT disappear. + this.activeBlockResets.update(m => { + const list = m.get(blockId); + if (!list || list.length === 0) return m; + const targetType = isThinking ? 'thinking' : 'text'; + const updated = list.map(r => r.streamType === targetType ? { ...r, isWaiting: false } : r); + const next = new Map(m); + next.set(blockId, updated); + return next; + }); + this.typingTargets.set(key, { blockId, isThinking, execId, fallbackText: targetText }); this.ensureTypingLoop(); } @@ -771,7 +899,7 @@ export class AgentStreamComponent implements AfterViewInit { this.lastTypingTimestamp = now; this.advanceTyping(elapsed); } - if (this.typingTargets.size > 0) { + if (this.typingTargets.size > 0 || this.rewindTargets.size > 0) { this.typingRafId = requestAnimationFrame(step); } }; @@ -781,6 +909,7 @@ export class AgentStreamComponent implements AfterViewInit { private stopTypingLoop(): void { this.typingTargets.clear(); + this.rewindTargets.clear(); if (this.typingRafId !== null) { cancelAnimationFrame(this.typingRafId); this.typingRafId = null; @@ -798,9 +927,14 @@ export class AgentStreamComponent implements AfterViewInit { 1, Math.round(AgentStreamComponent.TYPING_CHARS_PER_SECOND * elapsedMs / 1000) ); + const rewindChars = Math.max( + 2, + Math.round(AgentStreamComponent.REWIND_CHARS_PER_SECOND * elapsedMs / 1000) + ); const updates: Record = {}; let hasUpdates = false; + // 1. Advance forward typing targets for (const [key, typing] of this.typingTargets) { const block = blocks.find((b) => b.id === typing.blockId); let target = typing.fallbackText; @@ -821,11 +955,211 @@ export class AgentStreamComponent implements AfterViewInit { } } + // 2. Advance fast rewind targets (backspacing for mid-stream resets) + const completedRewinds: Array<{ blockId: string; execId?: string; isThinking: boolean; message: string }> = []; + for (const [key, rewind] of this.rewindTargets) { + const current = updates[key] !== undefined ? updates[key] : (currentRecord[key] || ''); + if (current.length > 0) { + const nextLen = Math.max(0, current.length - rewindChars); + updates[key] = current.slice(0, nextLen); + hasUpdates = true; + if (nextLen === 0) { + this.rewindTargets.delete(key); + completedRewinds.push({ + blockId: rewind.blockId, + execId: rewind.execId, + isThinking: rewind.isThinking, + message: rewind.message + }); + } + } else { + this.rewindTargets.delete(key); + completedRewinds.push({ + blockId: rewind.blockId, + execId: rewind.execId, + isThinking: rewind.isThinking, + message: rewind.message + }); + } + } + + if (completedRewinds.length > 0) { + for (const item of completedRewinds) { + this.upsertBlockResetNotice(item.blockId, { + id: item.execId || `${item.blockId}-${item.isThinking ? 'thinking' : 'text'}`, + message: item.message, + isWaiting: true, + streamType: item.isThinking ? 'thinking' : 'text' + }); + } + this.activeResetNotices.update((notices) => { + const next = new Map(notices); + for (const item of completedRewinds) { + next.set(item.blockId, item.message); + } + return next; + }); + } + if (hasUpdates) { this.typedTextsSignal.update((r) => ({ ...r, ...updates })); } } + /** + * Triggers fast typewriter rewind on a stream execution when a mid-stream reset occurs. + * Once characters rewind to zero, the block displays the graceful retry notification. + */ + public triggerStreamRewind(execId: string | undefined, message: string): void { + const blocks = untracked(() => this.consolidatedBlocks()); + const currentTexts = untracked(() => this.typedTextsSignal()); + + const targetBlock = blocks.find(b => + b.data?.execution_id === execId + || b.id === `stream-${execId}` + || b.data?.step_id === execId + || (Array.isArray(b.data?.stream_segments) && b.data.stream_segments.some((s: any) => s.execution_id === execId)) + ) || blocks.find(b => b.data?.isReset); + + if (!targetBlock) return; + const blockId = targetBlock.id; + + const keysToCheck: Array<{ key: string; isThinking: boolean }> = [ + { key: blockId, isThinking: false }, + { key: blockId + '-native', isThinking: true } + ]; + + let startedRewind = false; + for (const { key, isThinking } of keysToCheck) { + this.typingTargets.delete(key); + + const currentText = currentTexts[key] || ''; + if (currentText.length > 0) { + this.rewindTargets.set(key, { + blockId, + isThinking, + execId, + message + }); + startedRewind = true; + } + } + + if (startedRewind) { + this.ensureTypingLoop(); + } else { + this.upsertBlockResetNotice(blockId, { + id: execId || `${blockId}-text`, + message, + isWaiting: true, + streamType: 'text' + }); + this.activeResetNotices.update(notices => { + const next = new Map(notices); + next.set(blockId, message); + return next; + }); + } + } + + public getBlockResets(block: any, isThinking: boolean = false): StreamResetNotice[] { + if (!block) return []; + const blockId = block.id; + const targetStreamType = isThinking ? 'thinking' : 'text'; + + // 1. From activeBlockResets signal + const activeList = (this.activeBlockResets().get(blockId) || []) + .filter(r => r.streamType === targetStreamType); + + // 2. From block.data.stream_resets (aggregated from logs) + const blockDataResets = (Array.isArray(block.data?.stream_resets) ? block.data.stream_resets : []) + .filter((r: any) => (r.streamType || 'text') === targetStreamType); + + // Fast path: if both lists are empty, check fallback or return empty array immediately + if (activeList.length === 0 && blockDataResets.length === 0) { + if (!isThinking && block.data?.isReset && block.data?.resetMessage) { + return [{ + id: block.data.execution_id || blockId, + message: block.data.resetMessage, + isWaiting: !block.data?.isCompleted, + streamType: 'text' + }]; + } + return []; + } + + // Merge by id, giving precedence to activeList + const mergedMap = new Map(); + for (const r of blockDataResets) { + mergedMap.set(r.id, { + id: r.id, + message: r.message, + isWaiting: block.data?.isCompleted ? false : (r.isWaiting ?? false), + streamType: r.streamType || targetStreamType + }); + } + for (const r of activeList) { + mergedMap.set(r.id, { + ...r, + isWaiting: block.data?.isCompleted ? false : r.isWaiting + }); + } + + return Array.from(mergedMap.values()); + } + + public hasWaitingReset(block: any, isThinking: boolean = false): boolean { + const resets = this.getBlockResets(block, isThinking); + return resets.some(r => r.isWaiting); + } + + public hasBlockResets(block: any, isThinking: boolean = false): boolean { + return this.getBlockResets(block, isThinking).length > 0; + } + + public formatResetMessage(message: string, isWaiting: boolean): string { + if (!message) return ''; + if (isWaiting) return message; + return message + .replace(/[,.\s]*(?:Retrying automatically(?:\.{3}|\.\.\.)?)\s*$/i, '') + .trim(); + } + + public getCheckerSegmentResetMessage(item: any): string { + return item?.data?.resetMessage || DEFAULT_STREAM_RESET_MESSAGE; + } + + public getResetNotice(block: any): string | null { + if (!block) return null; + const resets = this.getBlockResets(block, false); + if (resets.length > 0) { + const last = resets[resets.length - 1]; + return this.formatResetMessage(last.message, last.isWaiting); + } + const blockId = block.id; + const active = this.activeResetNotices().get(blockId); + if (active) return this.formatResetMessage(active, !block.data?.isCompleted); + if (block.data?.isReset && block.data?.resetMessage) { + return this.formatResetMessage(block.data.resetMessage, !block.data?.isCompleted); + } + return null; + } + + public isStreamRewinding(key: string): boolean { + return this.rewindTargets.has(key); + } + + public isCheckerSegmentResetNotice(item: any): boolean { + if (!item?.data?.isReset) return false; + const text = item.data.text; + return !text || text.trim().length === 0; + } + + public getStreamDisplayText(key: string, fallback: string = ''): string { + const typed = this.typedTextsSignal()[key]; + return typed !== undefined ? typed : fallback; + } + private triggerDelayedCollapse(execId: string) { if (!this.scheduledCollapses.has(execId)) { this.scheduledCollapses.add(execId); @@ -1275,6 +1609,7 @@ export class AgentStreamComponent implements AfterViewInit { } public isCheckerTextVisible(item: any): boolean { + if (item?.data?.isReset) return true; const text = item?.data?.text; return typeof text === 'string' && text.trim().length > 0 && this.isHumanThinking(text); } @@ -1347,6 +1682,7 @@ export class AgentStreamComponent implements AfterViewInit { if (block.type === 'checker') return true; const hasNative = Boolean(this.getNativeThinking(block)); const hasRaw = Boolean(this.getRawThinking(block)); + const hasReset = this.hasBlockResets(block, false) || this.hasBlockResets(block, true); const hasVisibleTools = Boolean(block.data?.generic_tools) && block.data.generic_tools.some((t: any) => this.shouldShowTool(t, block.data) || this.isDisplayableLLMFailure(t) @@ -1354,7 +1690,7 @@ export class AgentStreamComponent implements AfterViewInit { || this.isReportStatusAction(t) ); const hasAndroidActions = Boolean(block.data?.action_taken) && (this.isAndroidAction(block.data.action_taken) || this.isReportStatusAction(block.data.action_taken)); - return hasNative || hasRaw || hasVisibleTools || hasAndroidActions; + return hasNative || hasRaw || hasReset || hasVisibleTools || hasAndroidActions; } public isReportStatusAction(action: any): boolean { diff --git a/apps/showcase_ui/src/app/core/models/stream.model.ts b/apps/showcase_ui/src/app/core/models/stream.model.ts index 0790bd7..0676ab7 100644 --- a/apps/showcase_ui/src/app/core/models/stream.model.ts +++ b/apps/showcase_ui/src/app/core/models/stream.model.ts @@ -52,6 +52,32 @@ export interface LLMStreamEventData { chunk?: string; stream_type?: 'thinking' | 'text' | string; isCompleted?: boolean; + isReset?: boolean; + resetMessage?: string; +} + +export const DEFAULT_STREAM_RESET_MESSAGE = + 'A request error occurred during output generation, typically caused by lower API priority. Retrying automatically...'; + +export interface StreamResetNotice { + id: string; + message: string; + isWaiting: boolean; + streamType: string; +} + +export interface LLMStreamResetEventData { + stream_exec_id?: string; + stream_execution_id?: string; + step_id?: string; + session_id?: string; + action?: 'discard' | string; + reason?: string; + category?: string; + error?: string; + message?: string; + retry_attempt?: number; + timestamp?: number; } export interface StepItemData { @@ -119,6 +145,8 @@ export interface StreamSegment { text: string; timestamp: string; isCompleted?: boolean; + isReset?: boolean; + resetMessage?: string; } /** diff --git a/apps/showcase_ui/src/app/services/agent.service.ts b/apps/showcase_ui/src/app/services/agent.service.ts index 88055d4..348b61b 100644 --- a/apps/showcase_ui/src/app/services/agent.service.ts +++ b/apps/showcase_ui/src/app/services/agent.service.ts @@ -20,9 +20,9 @@ import { Observable } from 'rxjs'; import { Session, ModelInfo, TaskQueueItem, AgentStatusResponse } from '../core/models/session.model'; import { ProTuningDefaults, ProTuningOptions } from '../core/models/pro-tuning.model'; -import { StepItemData, StepReplayFrame } from '../core/models/stream.model'; +import { StepItemData, StepReplayFrame, LLMStreamResetEventData, StreamResetNotice, DEFAULT_STREAM_RESET_MESSAGE } from '../core/models/stream.model'; import { extractStepReplayFrames } from '../utils/action-formatter.util'; -export type { Session, ModelInfo, TaskQueueItem, AgentStatusResponse, StepItemData, StepReplayFrame }; +export type { Session, ModelInfo, TaskQueueItem, AgentStatusResponse, StepItemData, StepReplayFrame, LLMStreamResetEventData, StreamResetNotice }; const SESSION_CACHE_KEY = 'artemis.sessions.v1'; @@ -240,6 +240,7 @@ export class AgentService { public videoSeekRequest = signal<{ seconds: number; requestId: number } | null>(null); public stepSeekRequest = signal<{ index: number; requestId: number } | null>(null); public playerMode = signal<'video' | 'steps'>('video'); + public streamResetEvent = signal(null); private activeVideoSessionId: string | null = null; private videoSeekRequestId = 0; private stepSeekRequestId = 0; @@ -348,7 +349,7 @@ export class AgentService { // Streaming chunks are coalesced into one signal update per short window so a // chatty LLM stream cannot force a full recompute pass per SSE chunk. - private pendingStreamChunks = new Map(); + private pendingStreamChunks = new Map(); private streamFlushTimer: ReturnType | null = null; private lastQueueSignature: string | null = null; private lastActiveTasksSignature: string | null = null; @@ -762,6 +763,7 @@ export class AgentService { const eventTypes = [ 'llm_stream', + 'llm_stream_reset', 'trace_recorded', 'step_recorded', 'step_updated', @@ -951,6 +953,67 @@ export class AgentService { this.isRetrying.set(false); } + if (eventType === 'llm_stream_reset') { + const streamExecId = parsedData?.stream_exec_id || parsedData?.stream_execution_id; + if (streamExecId) { + for (const key of Array.from(this.pendingStreamChunks.keys())) { + if (key.startsWith(`${streamExecId}|`) || key === streamExecId) { + this.pendingStreamChunks.delete(key); + } + } + } + const resetMessage = parsedData?.message || DEFAULT_STREAM_RESET_MESSAGE; + this.streamResetEvent.set({ + stream_exec_id: streamExecId, + stream_execution_id: streamExecId, + step_id: parsedData?.step_id, + session_id: parsedData?.session_id, + action: parsedData?.action || 'discard', + reason: parsedData?.reason || 'mid_stream_failure', + category: parsedData?.category, + error: parsedData?.error, + message: resetMessage, + retry_attempt: parsedData?.retry_attempt, + timestamp: Date.now() + }); + + this.sessionLogs.update((logs) => { + let updated = false; + const next = logs.map((log) => { + if (log.type === 'llm_stream' && log.data?.execution_id === streamExecId) { + updated = true; + return { + ...log, + data: { + ...log.data, + isReset: true, + resetMessage + } + }; + } + return log; + }); + if (!updated && streamExecId) { + next.push({ + type: 'llm_stream', + timestamp: new Date().toISOString(), + session_id: parsedData?.session_id, + data: { + execution_id: streamExecId, + step_id: parsedData?.step_id, + stream_type: 'text', + text: '', + isCompleted: false, + isReset: true, + resetMessage + } + }); + } + return next; + }); + return; + } + if (eventType === 'llm_stream') { this.queueStreamChunk(parsedData); } else { @@ -1033,19 +1096,22 @@ export class AgentService { private queueStreamChunk(parsedData: any): void { const execId = parsedData.execution_id; const stepId = parsedData.step_id; + const sessionId = parsedData.session_id || null; // The emitting agent's trace: lets the aggregator route a concurrent // agent's stream (e.g. the Checker) to its own block instead of the // Operator step that happens to be current. const parentTraceId = parsedData.parent_trace_id || null; const streamType = parsedData.stream_type || 'text'; const key = `${execId}|${streamType}`; + const chunk = parsedData.chunk !== undefined ? parsedData.chunk : (parsedData.text || ''); const pending = this.pendingStreamChunks.get(key); if (pending) { - pending.chunk += parsedData.chunk; + pending.chunk += chunk; if (stepId) pending.stepId = stepId; + if (sessionId) pending.sessionId = sessionId; if (parentTraceId) pending.parentTraceId = parentTraceId; } else { - this.pendingStreamChunks.set(key, { execId, stepId, parentTraceId, streamType, chunk: parsedData.chunk }); + this.pendingStreamChunks.set(key, { execId, stepId, sessionId, parentTraceId, streamType, chunk }); } if (!this.streamFlushTimer) { const delay = typeof document !== 'undefined' && document.hidden ? 500 : 80; @@ -1083,9 +1149,11 @@ export class AgentService { const existingLog = next[existingIndex]; next[existingIndex] = { ...existingLog, + session_id: batch.sessionId || existingLog.session_id, data: { ...existingLog.data, - text: existingLog.data.text + batch.chunk, + session_id: batch.sessionId || existingLog.data?.session_id, + text: (existingLog.data.text || '') + batch.chunk, step_id: batch.stepId || existingLog.data.step_id, parent_trace_id: batch.parentTraceId || existingLog.data.parent_trace_id || null } @@ -1105,8 +1173,10 @@ export class AgentService { next.push({ type: 'llm_stream', timestamp: new Date().toISOString(), + session_id: batch.sessionId || undefined, data: { execution_id: batch.execId, + session_id: batch.sessionId || undefined, step_id: batch.stepId, parent_trace_id: batch.parentTraceId || null, text: batch.chunk, diff --git a/apps/showcase_ui/src/app/utils/action-formatter.util.ts b/apps/showcase_ui/src/app/utils/action-formatter.util.ts index 1966e45..e6e2b0a 100644 --- a/apps/showcase_ui/src/app/utils/action-formatter.util.ts +++ b/apps/showcase_ui/src/app/utils/action-formatter.util.ts @@ -18,13 +18,66 @@ import { ActionParam, StepReplayFrame } from '../core/models/stream.model'; import { extractNumbersFromCoordinateValue, isPureDirectionString, parseSequenceCoordinates } from './image-overlay.util'; import { cleanErrorMessage } from './tool-formatter.util'; +/** + * Safely parse JSON string if it looks like an object/array, otherwise return original value + */ +export function safeParseJson(val: any): any { + if (typeof val === 'string' && (val.trim().startsWith('{') || val.trim().startsWith('['))) { + try { + return JSON.parse(val); + } catch { + return val; + } + } + return val; +} + +// WeakMap memo for parsed action objects to avoid repeated JSON.parse and object allocation across change detection cycles +const actionObjectCache = new WeakMap(); + +function computeActionObject(action: any): any { + if (action && typeof action === 'object' && action.payload) { + const payload = typeof action.payload === 'string' ? safeParseJson(action.payload) : action.payload; + const innerAction = payload?.args?.action; + if (innerAction && typeof innerAction === 'object') { + return { + ...action, + ...innerAction, + name: action.name || innerAction.action || innerAction.name, + action: innerAction.action || action.name, + trace_id: action.trace_id, + timestamp: action.timestamp ?? innerAction.timestamp + }; + } + const innerArgs = payload?.args; + if (innerArgs && typeof innerArgs === 'object') { + return { + ...action, + ...innerArgs, + name: action.name || innerArgs.action || innerArgs.name, + action: innerArgs.action || action.name, + trace_id: action.trace_id, + timestamp: action.timestamp ?? innerArgs.timestamp + }; + } + } + return action; +} + /** * Safely extract action object from possible array or nested structure */ export function getActionObject(action: any): any { if (!action) return null; if (Array.isArray(action)) { - return action.length > 0 ? action[0] : null; + return action.length > 0 ? getActionObject(action[0]) : null; + } + if (typeof action === 'object') { + const cached = actionObjectCache.get(action); + if (cached !== undefined) return cached; + const res = computeActionObject(action); + actionObjectCache.set(action, res); + return res; } return action; } @@ -46,7 +99,7 @@ export function isAndroidAction(action: any): boolean { 'tap', 'click', 'click_sequence', 'input', 'input_text', 'swipe', 'scroll', 'press_key', 'press_home', 'press_back', 'launch_app', 'manage_app', 'open_app', 'focus_and_input_text', 'focus_and_clear_text', 'long_press', 'long_press_on', - 'wait_for_delay', 'wait' + 'wait_for_delay', 'wait', 'drag', 'drag_and_drop' ].includes(name); } @@ -543,7 +596,8 @@ export function isGenericToolTrace(actionData: any, stepData?: any): boolean { } } if (actionData.is_primary_step_action) return false; - return Boolean(actionData.trace_id || actionData.payload || actionData.type === 'tool' || (actionData.type === 'action' && actionData.trace_id)); + if (actionData.type === 'action' || isAndroidAction(actionData)) return false; + return Boolean(actionData.trace_id || actionData.payload || actionData.type === 'tool'); } interface ResolvedImageEntry { @@ -554,16 +608,6 @@ interface ResolvedImageEntry { const stepImageMapCache = new WeakMap }>(); -function safeParseJson(val: any): any { - if (typeof val === 'string' && (val.trim().startsWith('{') || val.trim().startsWith('['))) { - try { - return JSON.parse(val); - } catch { - return val; - } - } - return val; -} export function extractItemPreImage(item: any): string | null { const act = getActionObject(item); @@ -639,7 +683,10 @@ export function resolveStepImageMap(stepData: any): Map if (!stepData || typeof stepData !== 'object') return emptyMap; const toolsLen = Array.isArray(stepData.generic_tools) ? stepData.generic_tools.length : 0; - const cacheKey = `${stepData.step_id || ''}_${toolsLen}_${stepData.post_image_name || ''}_${stepData.pre_image_name || ''}`; + const toolsImageSig = Array.isArray(stepData.generic_tools) + ? stepData.generic_tools.map((t: any) => `${t?.trace_id || ''}:${t?.post_image_name || t?.post_screenshot || ''}`).join(',') + : ''; + const cacheKey = `${stepData.step_id || ''}_${toolsLen}_${toolsImageSig}_${stepData.post_image_name || ''}_${stepData.pre_image_name || ''}`; const cached = stepImageMapCache.get(stepData); if (cached && cached.key === cacheKey) { @@ -649,7 +696,11 @@ export function resolveStepImageMap(stepData: any): Map const map = new Map(); const events: Array<{ item: any; act: any; timestamp: number; isPrimary: boolean }> = []; - if (stepData.action_taken) { + const hasActionInTools = Array.isArray(stepData.generic_tools) && stepData.generic_tools.some( + (t: any) => t && (t.type === 'action' || isAndroidAction(t) || isReportStatusAction(t)) + ); + + if (!hasActionInTools && stepData.action_taken) { events.push({ item: stepData.action_taken, act: getActionObject(stepData.action_taken), @@ -661,11 +712,12 @@ export function resolveStepImageMap(stepData: any): Map if (Array.isArray(stepData.generic_tools)) { stepData.generic_tools.forEach((t: any) => { if (t) { + const isAction = t.type === 'action' || isAndroidAction(t) || isReportStatusAction(t); events.push({ item: t, act: getActionObject(t), timestamp: getEventTimestamp(t), - isPrimary: false + isPrimary: isAction }); } }); @@ -728,6 +780,19 @@ export function resolveStepImageMap(stepData: any): Map map.set(e.item, entry); if (act && act !== e.item) map.set(act, entry); if (act?.trace_id) map.set(act.trace_id, entry); + if (e.item?.trace_id) map.set(e.item.trace_id, entry); + } + + // Ensure stepData.action_taken cross-resolves to the action images + if (stepData.action_taken && lastActionItem) { + const lastEntry = map.get(lastActionItem); + if (lastEntry) { + map.set(stepData.action_taken, lastEntry); + const actObj = getActionObject(stepData.action_taken); + if (actObj && actObj !== stepData.action_taken) { + map.set(actObj, lastEntry); + } + } } stepImageMapCache.set(stepData, { key: cacheKey, map }); @@ -885,7 +950,9 @@ export function extractStepReplayFrames(logsOrSteps: any[]): StepReplayFrame[] { const visualCandidates: VisualCandidate[] = []; for (const stepData of stepsList) { - const act = stepData.action_taken; + const act = stepData.action_taken || (Array.isArray(stepData.generic_tools) + ? stepData.generic_tools.find((t: any) => t && (t.type === 'action' || isAndroidAction(t))) + : null); const preUrl = getStepPreImageUrl(stepData, act); const postUrl = getStepPostImageUrl(stepData, act); const primaryImg = preUrl || postUrl; diff --git a/apps/showcase_ui/src/app/utils/stream-aggregator.util.spec.ts b/apps/showcase_ui/src/app/utils/stream-aggregator.util.spec.ts index 4e7e3e7..83b51d5 100644 --- a/apps/showcase_ui/src/app/utils/stream-aggregator.util.spec.ts +++ b/apps/showcase_ui/src/app/utils/stream-aggregator.util.spec.ts @@ -349,3 +349,139 @@ describe('stream aggregator checker history relocation', () => { expect(checker.data.generic_tools.map((t: any) => t.trace_id)).toEqual(['tool-probe']); }); }); + +describe('stream aggregator single source of truth (Option A)', () => { + it('does not duplicate action_taken when generic_tools already contains the action trace', () => { + const events = getSortedStepEvents({ + timestamp: 100, + action_taken: [{ action: 'launch_app', app_name: 'Maps', timestamp: 102 }], + generic_tools: [ + { trace_id: 'tool-note', type: 'tool', name: 'update_note', timestamp: 101 }, + { trace_id: 'act-launch', type: 'action', name: 'launch_app', timestamp: 102, payload: { args: { action: { action: 'launch_app', app_name: 'Maps' } } } } + ] + }); + + expect(events.length).toBe(2); + expect(events.map(e => e.type)).toEqual(['tool', 'action']); + expect(events.map(e => e.data.name)).toEqual(['update_note', 'launch_app']); + }); + + it('retains multiple adb commands and tools alongside physical actions without dropping any', () => { + const events = getSortedStepEvents({ + timestamp: 100, + action_taken: [{ action: 'wait_for_delay', time_in_ms: 1000, timestamp: 105 }], + generic_tools: [ + { trace_id: 'adb-1', type: 'tool', name: 'run_adb_command', timestamp: 101, payload: { CommandLine: 'dumpsys telephony.registry' } }, + { trace_id: 'adb-2', type: 'tool', name: 'run_adb_command', timestamp: 102, payload: { CommandLine: 'am start -a ...' } }, + { trace_id: 'exp-1', type: 'tool', name: 'ask_explorer', timestamp: 103 }, + { trace_id: 'act-wait', type: 'action', name: 'wait_for_delay', timestamp: 104, payload: { args: { time_in_ms: 1000 } } } + ] + }); + + expect(events.length).toBe(4); + expect(events.map(e => e.type)).toEqual(['tool', 'tool', 'tool', 'action']); + expect(events.map(e => e.data.name)).toEqual(['run_adb_command', 'run_adb_command', 'ask_explorer', 'wait_for_delay']); + }); + + it('supports multi-action bursts sequentially from generic_tools', () => { + const events = getSortedStepEvents({ + timestamp: 100, + action_taken: [{ action: 'tap', coordinates: [200, 300] }], + generic_tools: [ + { trace_id: 'tap-1', type: 'action', name: 'tap', timestamp: 101, payload: { args: { action: { action: 'tap', coordinates: [100, 200] } } } }, + { trace_id: 'tap-2', type: 'action', name: 'tap', timestamp: 102, payload: { args: { action: { action: 'tap', coordinates: [200, 300] } } } } + ] + }); + + expect(events.length).toBe(2); + expect(events.every(e => e.type === 'action')).toBeTrue(); + expect(events[0].data.trace_id).toBe('tap-1'); + expect(events[1].data.trace_id).toBe('tap-2'); + }); + + it('correctly processes real trace 38fd934e Step 16 keeping ADB commands and wait_for_delay visible without duplicates', () => { + const step16 = { + step_id: '31e8c7ec-6b67-485e-94f4-355a853ccf8a', + step_number: 16, + action_taken: [{ action: 'wait_for_delay', time_in_ms: 1000 }], + operator_native_thinking: 'Let me check telephony and send SMS...', + operator_native_thinking_timestamp: 1788457630, + timestamp: 1788457630, + generic_tools: [ + { trace_id: 't-adb-1', parent_trace_id: 't-op', name: 'run_adb_command', type: 'tool', timestamp: 1788457631, status: 'success', payload: { args: { CommandLine: 'dumpsys telephony.registry' } } }, + { trace_id: 't-adb-1-sub', parent_trace_id: 't-adb-1', name: 'run_adb_command', type: 'tool', timestamp: 1788457631.5, status: 'success' }, + { trace_id: 't-adb-2', parent_trace_id: 't-op', name: 'run_adb_command', type: 'tool', timestamp: 1788457632, status: 'success', payload: { args: { CommandLine: 'am start -a android.intent.action.SENDTO ...' } } }, + { trace_id: 't-adb-2-sub', parent_trace_id: 't-adb-2', name: 'run_adb_command', type: 'tool', timestamp: 1788457632.5, status: 'success' }, + { trace_id: 't-exp', parent_trace_id: 't-op', name: 'ask_explorer', type: 'tool', timestamp: 1788457633, status: 'success', payload: { args: { query: 'any text on screen' } } }, + { trace_id: 't-exp-sub', parent_trace_id: 't-exp', name: 'ask_explorer', type: 'tool', timestamp: 1788457633.5, status: 'success' }, + { trace_id: 't-wait', parent_trace_id: 't-val', name: 'wait_for_delay', type: 'action', timestamp: 1788457635, status: 'success', payload: { args: { action: { action: 'wait_for_delay', time_in_ms: 1000 } } } }, + { trace_id: 't-safety', parent_trace_id: 't-wait', name: 'safety_net_pixel_validation', type: 'tool', timestamp: 1788457636, status: 'success' } + ] + }; + + const events = getSortedStepEvents(step16); + // 1 native thinking + 2 adb tools + 1 explorer tool + 1 wait_for_delay action + expect(events.length).toBe(5); + expect(events.map(e => e.type)).toEqual(['thinking', 'tool', 'tool', 'tool', 'action']); + expect(events.map(e => e.data.name || e.data.action || 'thinking')).toEqual([ + 'thinking', + 'run_adb_command', + 'run_adb_command', + 'ask_explorer', + 'wait_for_delay' + ]); + }); + + it('correctly handles mid-stream reset and supersedes the reset block on retry', () => { + // 1. Initial stream fails mid-stream and is marked reset + const resetLogs = [ + { + type: 'llm_stream', + timestamp: '2026-09-03T10:00:01.000Z', + data: { + execution_id: 'exec-failed-1', + stream_type: 'text', + text: 'Here is the aborted plan...', + isCompleted: false, + isReset: true, + resetMessage: 'A request error occurred during output generation, typically caused by lower API priority. Retrying automatically...' + } + } + ]; + + const initialBlocks = consolidateLogsToBlocks(resetLogs); + expect(initialBlocks.length).toBe(1); + expect(initialBlocks[0].data.isReset).toBeTrue(); + expect(initialBlocks[0].data.resetMessage).toBe('A request error occurred during output generation, typically caused by lower API priority. Retrying automatically...'); + + // getSortedStepEvents retains the reset state and message + const resetEvents = getSortedStepEvents(initialBlocks[0].data); + expect(resetEvents.length).toBe(1); + expect(resetEvents[0].type).toBe('text'); + expect(resetEvents[0].data.isReset).toBeTrue(); + + // 2. Retry stream arrives without step_id (e.g. pre-planning retry) + const retryLogs = [ + ...resetLogs, + { + type: 'llm_stream', + timestamp: '2026-09-03T10:00:03.000Z', + data: { + execution_id: 'exec-retry-2', + stream_type: 'text', + text: 'Here is the fresh completed plan.', + isCompleted: true + } + } + ]; + + const updatedBlocks = consolidateLogsToBlocks(retryLogs); + // Should supersede the reset block rather than creating an orphaned duplicate card + expect(updatedBlocks.length).toBe(1); + expect(updatedBlocks[0].data.execution_id).toBe('exec-retry-2'); + expect(updatedBlocks[0].data.isReset).toBeFalse(); + expect(updatedBlocks[0].data.operator_raw_thinking).toBe('Here is the fresh completed plan.'); + expect(updatedBlocks[0].data.isCompleted).toBeTrue(); + }); +}); + diff --git a/apps/showcase_ui/src/app/utils/stream-aggregator.util.ts b/apps/showcase_ui/src/app/utils/stream-aggregator.util.ts index ad17c9d..a07523e 100644 --- a/apps/showcase_ui/src/app/utils/stream-aggregator.util.ts +++ b/apps/showcase_ui/src/app/utils/stream-aggregator.util.ts @@ -14,9 +14,9 @@ * limitations under the License. */ -import { StepBlock, PhaseBlock, StepEvent, StreamSegment } from '../core/models/stream.model'; -import { isAndroidAction, isReportStatusAction, getReportStatusExplanation, getReportStatusValue } from './action-formatter.util'; -import { getUniqueGenericTools } from './tool-formatter.util'; +import { StepBlock, PhaseBlock, StepEvent, StreamSegment, StreamResetNotice, DEFAULT_STREAM_RESET_MESSAGE } from '../core/models/stream.model'; +import { isAndroidAction, isReportStatusAction, getReportStatusExplanation, getReportStatusValue, getActionObject } from './action-formatter.util'; +import { getUniqueGenericTools, isInternalPlumbingTool } from './tool-formatter.util'; /** * Helper to safely extract milliseconds timestamp @@ -193,6 +193,8 @@ export function consolidateLogsToBlocks(rawLogs: any[]): StepBlock[] { const streamType = log.data?.stream_type || 'text'; const text = log.data?.text || ''; const isCompleted = log.data?.isCompleted ?? false; + const isReset = log.data?.isReset ?? false; + const resetMessage = log.data?.resetMessage; // The Checker's own reasoning streams under its agent trace: route it to // the attempt block, never to the Operator step that is current. @@ -215,7 +217,9 @@ export function consolidateLogsToBlocks(rawLogs: any[]): StepBlock[] { stream_type: streamType === 'thinking' ? 'thinking' : 'text', text, timestamp: segmentIndex > -1 ? segments[segmentIndex].timestamp : log.timestamp, - isCompleted + isCompleted, + isReset, + resetMessage }; if (segmentIndex > -1) segments[segmentIndex] = segment; else segments.push(segment); checkerData.stream_segments = segments; @@ -248,10 +252,42 @@ export function consolidateLogsToBlocks(rawLogs: any[]): StepBlock[] { if (existingIndex === -1 && execId) { existingIndex = blocks.findIndex(b => b.data?.execution_id === execId || b.id === `stream-${execId}`); } + // If no stepId and not matched by execId, check if there is an unattached stream block that was reset. + // The retry stream supersedes that reset block rather than spawning an orphaned duplicate card. + if (existingIndex === -1 && !stepId) { + existingIndex = blocks.findIndex(b => b.type === 'llm_stream' && b.data?.isReset); + } if (existingIndex > -1) { const existing = blocks[existingIndex]; const updatedData = { ...existing.data }; + const existingResets: StreamResetNotice[] = + Array.isArray(updatedData.stream_resets) ? [...updatedData.stream_resets] : []; + + if (isReset) { + updatedData.isReset = true; + updatedData.resetMessage = resetMessage; + const msg = resetMessage || DEFAULT_STREAM_RESET_MESSAGE; + const resetId = execId || `reset-${log.timestamp || updatedData.step_id || 'stream'}`; + const existingResetIdx = existingResets.findIndex(r => r.id === resetId); + if (existingResetIdx > -1) { + existingResets[existingResetIdx] = { id: resetId, message: msg, isWaiting: true, streamType }; + } else { + existingResets.push({ id: resetId, message: msg, isWaiting: true, streamType }); + } + updatedData.stream_resets = existingResets; + } else if (execId && updatedData.execution_id !== execId) { + // New execution replacing a reset stream: mark prior resets as finished waiting + updatedData.stream_resets = existingResets.map(r => ({ ...r, isWaiting: false })); + updatedData.isReset = false; + updatedData.resetMessage = undefined; + updatedData.execution_id = execId; + } else if (!isReset && text && text.length > 0 && existingResets.some(r => r.isWaiting)) { + updatedData.stream_resets = existingResets.map(r => ({ ...r, isWaiting: false })); + updatedData.isReset = false; + updatedData.resetMessage = undefined; + } + if (streamType === 'thinking') { updatedData.operator_native_thinking = text; updatedData.operator_native_thinking_timestamp = @@ -268,6 +304,9 @@ export function consolidateLogsToBlocks(rawLogs: any[]): StepBlock[] { updatedData.execution_id = execId; } updatedData.isCompleted = isCompleted; + if (isCompleted && updatedData.stream_resets) { + updatedData.stream_resets = updatedData.stream_resets.map((r: any) => ({ ...r, isWaiting: false })); + } blocks[existingIndex] = { ...existing, @@ -279,6 +318,14 @@ export function consolidateLogsToBlocks(rawLogs: any[]): StepBlock[] { execution_id: execId, step_id: stepId, isCompleted: isCompleted, + isReset: isReset, + resetMessage: resetMessage, + stream_resets: isReset ? [{ + id: execId || `reset-${log.timestamp || stepId || 'stream'}`, + message: resetMessage || DEFAULT_STREAM_RESET_MESSAGE, + isWaiting: true, + streamType + }] : [], generic_tools: [] }; if (streamType === 'thinking') { @@ -338,6 +385,9 @@ export function consolidateLogsToBlocks(rawLogs: any[]): StepBlock[] { generic_tools: mergedTools, isCompleted: true }; + if (mergedData.stream_resets) { + mergedData.stream_resets = mergedData.stream_resets.map((r: any) => ({ ...r, isWaiting: false })); + } blocks[existingIndex] = { ...existingBlock, @@ -667,6 +717,9 @@ export function getSortedStepEvents( ): StepEvent[] { if (!stepData || typeof stepData !== 'object') return []; const toolsLen = stepData.generic_tools ? stepData.generic_tools.length : 0; + const toolsSignature = stepData.generic_tools && Array.isArray(stepData.generic_tools) + ? stepData.generic_tools.map((t: any) => `${t?.trace_id || t?.name || ''}:${t?.status || ''}`).join(',') + : ''; const actionTs = stepData.action_taken ? (stepData.action_taken.timestamp || stepData.action_taken.start_time || stepData.action_taken.created_at) : null; const nativeText = typeof stepData.operator_native_thinking === 'string' ? stepData.operator_native_thinking.trim() @@ -679,11 +732,15 @@ export function getSortedStepEvents( : null; const signature = [ toolsLen, + toolsSignature, actionTs ?? '', stepData.operator_native_thinking_timestamp ?? '', stepData.operator_raw_thinking_timestamp ?? '', nativeText.length, rawText.length, + stepData.isReset ? '1' : '0', + stepData.resetMessage ?? '', + stepData.stream_resets ? stepData.stream_resets.map((r: any) => `${r.id}:${r.isWaiting ? 1 : 0}`).join(',') : '', segments ? segments.map((s) => `${s.execution_id}:${s.stream_type}:${s.text.length}`).join(',') : '' ].join('|'); @@ -704,38 +761,58 @@ export function getSortedStepEvents( // Multi-turn agent (Checker): one event per streamed turn, each at the // time its first chunk arrived, so turns sort between the tool calls. segments.forEach((segment) => { - if (!segment.text || !segment.text.trim()) return; + if ((!segment.text || !segment.text.trim()) && !segment.isReset) return; events.push({ type: segment.stream_type === 'thinking' ? 'thinking' : 'text', - data: { text: segment.text, execution_id: segment.execution_id, segment: true }, + data: { + text: segment.text, + execution_id: segment.execution_id, + segment: true, + isReset: segment.isReset, + resetMessage: segment.resetMessage + }, timestamp: getItemTimestamp(segment.timestamp, fallbackTime), sequence: sequence++ }); }); } else { - if (nativeText) { + const hasThinkingResets = Array.isArray(stepData.stream_resets) && stepData.stream_resets.some((r: any) => r.streamType === 'thinking'); + if (nativeText || hasThinkingResets) { events.push({ type: 'thinking', - data: { text: stepData.operator_native_thinking }, + data: { text: stepData.operator_native_thinking || '' }, timestamp: getItemTimestamp(stepData.operator_native_thinking_timestamp, fallbackTime), sequence: sequence++ }); } - if (rawText) { + if (rawText || (stepData.isReset && stepData.resetMessage) || (Array.isArray(stepData.stream_resets) && stepData.stream_resets.length > 0)) { events.push({ type: 'text', - data: { text: stepData.operator_raw_thinking }, + data: { + text: stepData.operator_raw_thinking || '', + isReset: stepData.isReset, + resetMessage: stepData.resetMessage + }, timestamp: getItemTimestamp(stepData.operator_raw_thinking_timestamp, fallbackTime), sequence: sequence++ }); } } - // Add action_taken if present and valid. - if (stepData.action_taken && (isAndroidAction(stepData.action_taken) || isReportStatusAction(stepData.action_taken))) { + // 2. Single Source of Truth & Fallback Resolution: + const uniqueTools = getUniqueGenericTools(stepData.generic_tools); + const hasActionInTools = uniqueTools.some((tool: any) => + tool && (tool.type === 'action' || isAndroidAction(tool) || isReportStatusAction(tool)) + ); + + // Fallback: only add action_taken if generic_tools did NOT already contain an action trace. + // Pushed before uniqueTools so that when timestamps are missing/equal, the primary action + // preserves deterministic sequence ordering ahead of generic fallback tools. + if (!hasActionInTools && stepData.action_taken && (isAndroidAction(stepData.action_taken) || isReportStatusAction(stepData.action_taken))) { + const actObj = getActionObject(stepData.action_taken); const actTime = getItemTimestamp( - stepData.action_taken.timestamp ?? stepData.action_taken.start_time ?? stepData.action_taken.created_at, + actObj?.timestamp ?? actObj?.start_time ?? actObj?.created_at, fallbackTime ); events.push({ @@ -746,15 +823,19 @@ export function getSortedStepEvents( }); } - // Add generic tools if present. - const uniqueTools = getUniqueGenericTools(stepData.generic_tools); + // Add all real tool calls and actions from the event stream. uniqueTools.forEach((tool: any) => { + if (isInternalPlumbingTool(tool) && !isReportStatusAction(tool)) { + return; + } + + const isAction = tool.type === 'action' || isAndroidAction(tool) || isReportStatusAction(tool); const toolTime = getItemTimestamp( tool.timestamp ?? tool.start_time ?? tool.created_at, fallbackTime ); events.push({ - type: 'tool', + type: isAction ? 'action' : 'tool', data: tool, timestamp: toolTime, sequence: sequence++ diff --git a/apps/showcase_ui/src/app/utils/tool-formatter.util.spec.ts b/apps/showcase_ui/src/app/utils/tool-formatter.util.spec.ts index 85635d9..c831e29 100644 --- a/apps/showcase_ui/src/app/utils/tool-formatter.util.spec.ts +++ b/apps/showcase_ui/src/app/utils/tool-formatter.util.spec.ts @@ -1,7 +1,8 @@ import { cleanErrorMessage, getUniqueGenericTools, - getVideoAnalysisView + getVideoAnalysisView, + shouldShowTool } from './tool-formatter.util'; describe('cleanErrorMessage', () => { @@ -164,3 +165,38 @@ describe('video analysis timeline formatting', () => { ]); }); }); + +describe('shouldShowTool (Option A Single Source of Truth)', () => { + it('keeps ADB commands visible even when stepData.action_taken is present', () => { + const adbTool = { + name: 'run_adb_command', + type: 'tool', + payload: { CommandLine: 'dumpsys telephony.registry' } + }; + const stepData = { + action_taken: [{ action: 'wait_for_delay', time_in_ms: 1000 }] + }; + + expect(shouldShowTool(adbTool, stepData)).toBeTrue(); + }); + + it('keeps explorer calls visible when stepData.action_taken is present', () => { + const explorerTool = { + name: 'ask_explorer', + type: 'tool' + }; + const stepData = { + action_taken: [{ action: 'wait_for_delay', time_in_ms: 1000 }] + }; + + expect(shouldShowTool(explorerTool, stepData)).toBeTrue(); + }); + + it('filters out internal plumbing tools', () => { + const plumbingTool = { + name: 'safety_net_pixel_validation', + type: 'tool' + }; + expect(shouldShowTool(plumbingTool)).toBeFalse(); + }); +}); diff --git a/apps/showcase_ui/src/app/utils/tool-formatter.util.ts b/apps/showcase_ui/src/app/utils/tool-formatter.util.ts index a2246b7..f47a5c5 100644 --- a/apps/showcase_ui/src/app/utils/tool-formatter.util.ts +++ b/apps/showcase_ui/src/app/utils/tool-formatter.util.ts @@ -15,7 +15,6 @@ */ import { ActionParam } from '../core/models/stream.model'; -import { isAndroidAction, isReportStatusAction } from './action-formatter.util'; import { extractNumbersFromCoordinateValue, isPureDirectionString, parseSequenceCoordinates } from './image-overlay.util'; // Tool objects are replaced (not mutated) when a trace is updated, so a @@ -276,24 +275,12 @@ export function isInternalPlumbingTool(tool: any): boolean { /** * Determines whether a tool invocation should be displayed in the timeline. */ -export function shouldShowTool(tool: any, stepData?: any): boolean { +export function shouldShowTool(tool: any, _stepData?: any): boolean { if (!tool || !tool.name) return false; if (tool.type === 'llm_call') return false; if (tool.type === 'agent') return false; if (isInternalPlumbingTool(tool)) return false; - // If this tool is an operator/validator atomic action that duplicates stepData.action_taken, skip it - if (stepData && stepData.action_taken && isAndroidAction(stepData.action_taken) && isDeviceActionTool(tool)) { - const agentName = (tool.agent_name || tool.agent || '').toLowerCase(); - const isSelfHealingOrSubAgent = agentName.includes('failure') || - agentName.includes('analyzer') || - agentName.includes('diagnos') || - (agentName && agentName !== 'operator' && agentName !== 'flashrunner' && agentName !== 'validator'); - if (!isSelfHealingOrSubAgent) { - return false; - } - } - return true; } diff --git a/apps/showcase_ui/src/styles.scss b/apps/showcase_ui/src/styles.scss index 7d9ec92..0953441 100644 --- a/apps/showcase_ui/src/styles.scss +++ b/apps/showcase_ui/src/styles.scss @@ -16,6 +16,11 @@ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=Outfit:wght@300;400;500;600;700&family=Roboto:wght@400;500;700&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200'); +@import '@angular/cdk/overlay-prebuilt.css'; + +.cdk-overlay-container { + z-index: 1200 !important; +} *, *::before, *::after { box-sizing: border-box; diff --git a/artemis/services/llm.py b/artemis/services/llm.py index 2a73cf9..a3512e6 100644 --- a/artemis/services/llm.py +++ b/artemis/services/llm.py @@ -728,8 +728,13 @@ class RobustChatModelWrapper: "llm_stream_reset", { "stream_exec_id": str(stream_exec_id), + "action": "discard", + "reason": "mid_stream_failure", "error": str(stream_error)[:500], "category": classify_failure(stream_error).category.value, + "message": ( + "A request error occurred during output generation, typically caused by lower API priority. Retrying automatically..." + ), }, ) raise diff --git a/tests/unit/test_llm_gateway.py b/tests/unit/test_llm_gateway.py index 00bfc41..4edbbcf 100644 --- a/tests/unit/test_llm_gateway.py +++ b/tests/unit/test_llm_gateway.py @@ -204,3 +204,39 @@ async def test_acomplete_compat_path_for_plain_models(): result = await acomplete(InvokeOnly(), []) assert result.content == "direct" + + +@pytest.mark.asyncio +async def test_mid_stream_failure_records_stream_reset_payload(monkeypatch): + recorded_events = [] + + def mock_record_event(name, payload, *, status="retrying"): + recorded_events.append((name, payload, status)) + + monkeypatch.setattr(llm_service, "_record_llm_event", mock_record_event) + + class BreaksMidStream: + def __init__(self): + self.calls = 0 + + async def astream(self, *args, **kwargs): + self.calls += 1 + if self.calls == 1: + yield AIMessageChunk(content="Partial thoughts...") + raise RuntimeError("503 high demand spike") + yield AIMessageChunk(content="Recovered output") + + base = BreaksMidStream() + wrapper = RobustChatModelWrapper(base) + result = await wrapper.complete([]) + + assert result.content == "Recovered output" + resets = [e for e in recorded_events if e[0] == "llm_stream_reset"] + assert len(resets) == 1 + event_name, payload, status = resets[0] + assert event_name == "llm_stream_reset" + assert payload["action"] == "discard" + assert payload["reason"] == "mid_stream_failure" + assert "stream_exec_id" in payload + assert "lower API priority" in payload["message"] +