Compare commits

..

11 Commits

Author SHA1 Message Date
mingholy.lmh
37b65a1940 chore: update release workflows using bot PAT 2026-01-06 21:19:03 +08:00
mingholy.lmh
b950578990 chore: update release workflows to improve versioning and safety. 2025-12-30 14:20:04 +08:00
Mingholy
105ad743fa Merge pull request #1284 from tt-a1i/fix/boolean-string-coercion
fix(core): coerce string boolean values in schema validation
2025-12-29 18:27:36 +08:00
mingholy.lmh
ac3f7cb8c8 fix: ts erros in test file 2025-12-29 17:20:25 +08:00
顾盼
e27e9a5f18 Merge pull request #1288 from Weaxs/main
support merge ChatCompletionContentPart && add filterEmptyMessages
2025-12-29 10:50:30 +08:00
pomelo
2578d8c151 Merge pull request #1360 from IceyLiu/icey-feat
docs: add AionUi to ecosystem section
2025-12-29 10:12:53 +08:00
VeryLiu-lab
a877fedc52 docs: add AionUi to ecosystem section
Add AionUi as a graphical interface option for Qwen Code users.
AionUi provides a modern GUI for command-line AI tools including
Qwen Code, offering an alternative to the terminal interface.
2025-12-28 21:56:59 +08:00
Weaxs
d2bc46cbb4 remove filterEmptyMessages 2025-12-18 00:55:47 +08:00
Weaxs
84eb5c562f support merge ChatCompletionContentPart && add filterEmptyMessages 2025-12-18 00:46:48 +08:00
Tu Shaokun
7b01b26ff5 perf(core): avoid recompiling schema on retry 2025-12-17 16:27:42 +08:00
Tu Shaokun
0f3e97ea1c fix(core): coerce string boolean values in schema validation
Self-hosted LLMs sometimes return "true"/"false" strings instead of
actual boolean values for tool parameters like `is_background`. This
causes schema validation to fail with type errors.

Fixes #1267
2025-12-17 16:14:02 +08:00
15 changed files with 556 additions and 89 deletions

View File

@@ -34,7 +34,8 @@ on:
default: false
concurrency:
group: '${{ github.workflow }}'
# Serialize all release workflows (CLI + SDK) to avoid racing on `main` pushes.
group: 'release-main'
cancel-in-progress: false
jobs:
@@ -50,7 +51,6 @@ jobs:
packages: 'write'
id-token: 'write'
issues: 'write'
pull-requests: 'write'
outputs:
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
@@ -128,12 +128,13 @@ jobs:
IS_PREVIEW: '${{ steps.vars.outputs.is_preview }}'
MANUAL_VERSION: '${{ inputs.version }}'
- name: 'Set SDK package version (local only)'
- name: 'Set SDK package version'
env:
RELEASE_VERSION: '${{ steps.version.outputs.RELEASE_VERSION }}'
run: |-
# Ensure the package version matches the computed release version.
# This is required for nightly/preview because npm does not allow re-publishing the same version.
# Using --no-git-tag-version because we create tags via GitHub Release, not npm.
npm version -w @qwen-code/sdk "${RELEASE_VERSION}" --no-git-tag-version --allow-same-version
- name: 'Build CLI Bundle'
@@ -168,37 +169,40 @@ jobs:
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: 'Build SDK'
working-directory: 'packages/sdk-typescript'
run: |-
npm run build
- name: 'Publish @qwen-code/sdk'
working-directory: 'packages/sdk-typescript'
run: |-
npm publish --access public --tag=${{ steps.version.outputs.NPM_TAG }} ${{ steps.vars.outputs.is_dry_run == 'true' && '--dry-run' || '' }}
env:
NODE_AUTH_TOKEN: '${{ secrets.NPM_TOKEN }}'
- name: 'Create and switch to a release branch'
- name: 'Create and switch to a release branch (stable only)'
if: |-
${{ steps.vars.outputs.is_dry_run == 'false' && steps.vars.outputs.is_nightly == 'false' && steps.vars.outputs.is_preview == 'false' }}
id: 'release_branch'
env:
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
run: |-
set -euo pipefail
BRANCH_NAME="release/sdk-typescript/${RELEASE_TAG}"
git switch -c "${BRANCH_NAME}"
# Make reruns idempotent: reuse an existing remote branch if it already exists.
if git show-ref --verify --quiet "refs/heads/${BRANCH_NAME}"; then
git switch "${BRANCH_NAME}"
elif git ls-remote --exit-code --heads origin "${BRANCH_NAME}" >/dev/null 2>&1; then
git fetch origin "${BRANCH_NAME}:${BRANCH_NAME}"
git switch "${BRANCH_NAME}"
else
git switch -c "${BRANCH_NAME}"
fi
echo "BRANCH_NAME=${BRANCH_NAME}" >> "${GITHUB_OUTPUT}"
- name: 'Commit and Push package version (stable only)'
- name: 'Build SDK'
working-directory: 'packages/sdk-typescript'
run: |-
npm run build
- name: 'Commit and Push package version to release branch (stable only)'
if: |-
${{ steps.vars.outputs.is_dry_run == 'false' && steps.vars.outputs.is_nightly == 'false' && steps.vars.outputs.is_preview == 'false' }}
env:
BRANCH_NAME: '${{ steps.release_branch.outputs.BRANCH_NAME }}'
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
run: |-
# Only persist version bumps after a successful publish.
git add packages/sdk-typescript/package.json package-lock.json
if git diff --staged --quiet; then
echo "No version changes to commit"
@@ -208,9 +212,47 @@ jobs:
echo "Pushing release branch to remote..."
git push --set-upstream origin "${BRANCH_NAME}" --follow-tags
- name: 'Create GitHub Release and Tag'
- name: 'Check if @qwen-code/sdk version is already published (rerun safety)'
id: 'npm_check'
env:
RELEASE_VERSION: '${{ steps.version.outputs.RELEASE_VERSION }}'
run: |-
set -euo pipefail
if npm view "@qwen-code/sdk@${RELEASE_VERSION}" version >/dev/null 2>&1; then
echo "already_published=true" >> "${GITHUB_OUTPUT}"
echo "@qwen-code/sdk@${RELEASE_VERSION} already exists on npm."
else
echo "already_published=false" >> "${GITHUB_OUTPUT}"
fi
- name: 'Publish @qwen-code/sdk'
working-directory: 'packages/sdk-typescript'
if: |-
${{ steps.vars.outputs.is_dry_run == 'true' || steps.npm_check.outputs.already_published != 'true' }}
run: |-
npm publish --access public --tag=${{ steps.version.outputs.NPM_TAG }} ${{ steps.vars.outputs.is_dry_run == 'true' && '--dry-run' || '' }}
env:
NODE_AUTH_TOKEN: '${{ secrets.NPM_TOKEN }}'
- name: 'Check if GitHub Release already exists (rerun safety)'
if: |-
${{ steps.vars.outputs.is_dry_run == 'false' }}
id: 'gh_release_check'
env:
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
run: |-
set -euo pipefail
if gh release view "sdk-typescript-${RELEASE_TAG}" >/dev/null 2>&1; then
echo "already_exists=true" >> "${GITHUB_OUTPUT}"
echo "GitHub Release sdk-typescript-${RELEASE_TAG} already exists."
else
echo "already_exists=false" >> "${GITHUB_OUTPUT}"
fi
- name: 'Create GitHub Release and Tag'
if: |-
${{ steps.vars.outputs.is_dry_run == 'false' && steps.gh_release_check.outputs.already_exists != 'true' }}
env:
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
RELEASE_BRANCH: '${{ steps.release_branch.outputs.BRANCH_NAME }}'
@@ -236,48 +278,27 @@ jobs:
--generate-notes \
${PRERELEASE_FLAG}
- name: 'Create PR to merge release branch into main'
- name: 'Create release PR for SDK version bump'
if: |-
${{ steps.vars.outputs.is_dry_run == 'false' && steps.vars.outputs.is_nightly == 'false' && steps.vars.outputs.is_preview == 'false' }}
id: 'pr'
env:
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
RELEASE_BRANCH: '${{ steps.release_branch.outputs.BRANCH_NAME }}'
GH_TOKEN: '${{ secrets.CI_BOT_PAT }}'
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
RELEASE_BRANCH: '${{ steps.release_branch.outputs.BRANCH_NAME }}'
run: |-
set -euo pipefail
pr_url="$(gh pr list --head "${RELEASE_BRANCH}" --base main --json url --jq '.[0].url')"
if [[ -z "${pr_url}" ]]; then
pr_url="$(gh pr create \
--base main \
--head "${RELEASE_BRANCH}" \
--title "chore(release): sdk-typescript ${RELEASE_TAG}" \
--body "Automated release PR for sdk-typescript ${RELEASE_TAG}.")"
pr_exists=$(gh pr list --head "${RELEASE_BRANCH}" --state open --json number --jq 'length')
if [[ "${pr_exists}" != "0" ]]; then
echo "Open PR already exists for ${RELEASE_BRANCH}; skipping creation."
exit 0
fi
echo "PR_URL=${pr_url}" >> "${GITHUB_OUTPUT}"
- name: 'Wait for CI checks to complete'
if: |-
${{ steps.vars.outputs.is_dry_run == 'false' && steps.vars.outputs.is_nightly == 'false' && steps.vars.outputs.is_preview == 'false' }}
env:
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
PR_URL: '${{ steps.pr.outputs.PR_URL }}'
run: |-
set -euo pipefail
echo "Waiting for CI checks to complete..."
gh pr checks "${PR_URL}" --watch --interval 30
- name: 'Enable auto-merge for release PR'
if: |-
${{ steps.vars.outputs.is_dry_run == 'false' && steps.vars.outputs.is_nightly == 'false' && steps.vars.outputs.is_preview == 'false' }}
env:
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
PR_URL: '${{ steps.pr.outputs.PR_URL }}'
run: |-
set -euo pipefail
gh pr merge "${PR_URL}" --merge --auto
gh pr create \
--base main \
--head "${RELEASE_BRANCH}" \
--title "chore(release): sdk-typescript ${RELEASE_TAG}" \
--body "Automated SDK version bump for ${RELEASE_TAG}."
- name: 'Create Issue on Failure'
if: |-

View File

@@ -38,6 +38,11 @@ on:
type: 'boolean'
default: false
concurrency:
# Serialize all release workflows (CLI + SDK) to avoid racing on `main` pushes.
group: 'release-main'
cancel-in-progress: false
jobs:
release:
runs-on: 'ubuntu-latest'
@@ -150,8 +155,19 @@ jobs:
env:
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
run: |-
set -euo pipefail
BRANCH_NAME="release/${RELEASE_TAG}"
git switch -c "${BRANCH_NAME}"
# Make reruns idempotent: reuse an existing remote branch if it already exists.
if git show-ref --verify --quiet "refs/heads/${BRANCH_NAME}"; then
git switch "${BRANCH_NAME}"
elif git ls-remote --exit-code --heads origin "${BRANCH_NAME}" >/dev/null 2>&1; then
git fetch origin "${BRANCH_NAME}:${BRANCH_NAME}"
git switch "${BRANCH_NAME}"
else
git switch -c "${BRANCH_NAME}"
fi
echo "BRANCH_NAME=${BRANCH_NAME}" >> "${GITHUB_OUTPUT}"
- name: 'Update package versions'
@@ -191,16 +207,47 @@ jobs:
registry-url: 'https://registry.npmjs.org'
scope: '@qwen-code'
- name: 'Check if @qwen-code/qwen-code version is already published (rerun safety)'
id: 'npm_check'
env:
RELEASE_VERSION: '${{ steps.version.outputs.RELEASE_VERSION }}'
run: |-
set -euo pipefail
if npm view "@qwen-code/qwen-code@${RELEASE_VERSION}" version >/dev/null 2>&1; then
echo "already_published=true" >> "${GITHUB_OUTPUT}"
echo "@qwen-code/qwen-code@${RELEASE_VERSION} already exists on npm."
else
echo "already_published=false" >> "${GITHUB_OUTPUT}"
fi
- name: 'Publish @qwen-code/qwen-code'
working-directory: 'dist'
if: |-
${{ steps.vars.outputs.is_dry_run == 'true' || steps.npm_check.outputs.already_published != 'true' }}
run: |-
npm publish --access public --tag=${{ steps.version.outputs.NPM_TAG }} ${{ steps.vars.outputs.is_dry_run == 'true' && '--dry-run' || '' }}
env:
NODE_AUTH_TOKEN: '${{ secrets.NPM_TOKEN }}'
- name: 'Create GitHub Release and Tag'
- name: 'Check if GitHub Release already exists (rerun safety)'
if: |-
${{ steps.vars.outputs.is_dry_run == 'false' }}
id: 'gh_release_check'
env:
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
run: |-
set -euo pipefail
if gh release view "${RELEASE_TAG}" >/dev/null 2>&1; then
echo "already_exists=true" >> "${GITHUB_OUTPUT}"
echo "GitHub Release ${RELEASE_TAG} already exists."
else
echo "already_exists=false" >> "${GITHUB_OUTPUT}"
fi
- name: 'Create GitHub Release and Tag'
if: |-
${{ steps.vars.outputs.is_dry_run == 'false' && steps.gh_release_check.outputs.already_exists != 'true' }}
env:
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
RELEASE_BRANCH: '${{ steps.release_branch.outputs.BRANCH_NAME }}'
@@ -214,12 +261,34 @@ jobs:
--notes-start-tag "$PREVIOUS_RELEASE_TAG" \
--generate-notes
- name: 'Create release PR for version bump'
if: |-
${{ steps.vars.outputs.is_dry_run == 'false' && steps.vars.outputs.is_nightly == 'false' && steps.vars.outputs.is_preview == 'false' }}
env:
GH_TOKEN: '${{ secrets.CI_BOT_PAT }}'
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
RELEASE_BRANCH: '${{ steps.release_branch.outputs.BRANCH_NAME }}'
run: |-
set -euo pipefail
pr_exists=$(gh pr list --head "${RELEASE_BRANCH}" --state open --json number --jq 'length')
if [[ "${pr_exists}" != "0" ]]; then
echo "Open PR already exists for ${RELEASE_BRANCH}; skipping creation."
exit 0
fi
gh pr create \
--base main \
--head "${RELEASE_BRANCH}" \
--title "chore(release): ${RELEASE_TAG}" \
--body "Automated version bump for ${RELEASE_TAG}."
- name: 'Create Issue on Failure'
if: |-
${{ failure() }}
env:
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }} || "N/A"'
RELEASE_TAG: "${{ steps.version.outputs.RELEASE_TAG || 'N/A' }}"
DETAILS_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}'
run: |-
gh issue create \

View File

@@ -191,6 +191,7 @@ See [settings](https://qwenlm.github.io/qwen-code-docs/en/users/configuration/se
Looking for a graphical interface?
- [**AionUi**](https://github.com/iOfficeAI/AionUi) A modern GUI for command-line AI tools including Qwen Code
- [**Gemini CLI Desktop**](https://github.com/Piebald-AI/gemini-cli-desktop) A cross-platform desktop/web/mobile UI for Qwen Code
## Troubleshooting

12
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.6.0-nightly.20251227.2bc80795",
"version": "0.6.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@qwen-code/qwen-code",
"version": "0.6.0-nightly.20251227.2bc80795",
"version": "0.6.0",
"workspaces": [
"packages/*"
],
@@ -17316,7 +17316,7 @@
},
"packages/cli": {
"name": "@qwen-code/qwen-code",
"version": "0.6.0-nightly.20251227.2bc80795",
"version": "0.6.0",
"dependencies": {
"@google/genai": "1.30.0",
"@iarna/toml": "^2.2.5",
@@ -17953,7 +17953,7 @@
},
"packages/core": {
"name": "@qwen-code/qwen-code-core",
"version": "0.6.0-nightly.20251227.2bc80795",
"version": "0.6.0",
"hasInstallScript": true,
"dependencies": {
"@anthropic-ai/sdk": "^0.36.1",
@@ -21413,7 +21413,7 @@
},
"packages/test-utils": {
"name": "@qwen-code/qwen-code-test-utils",
"version": "0.6.0-nightly.20251227.2bc80795",
"version": "0.6.0",
"dev": true,
"license": "Apache-2.0",
"devDependencies": {
@@ -21425,7 +21425,7 @@
},
"packages/vscode-ide-companion": {
"name": "qwen-code-vscode-ide-companion",
"version": "0.6.0-nightly.20251227.2bc80795",
"version": "0.6.0",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.25.1",

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.6.0-nightly.20251227.2bc80795",
"version": "0.6.0",
"engines": {
"node": ">=20.0.0"
},
@@ -13,7 +13,7 @@
"url": "git+https://github.com/QwenLM/qwen-code.git"
},
"config": {
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.6.0-nightly.20251227.2bc80795"
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.6.0"
},
"scripts": {
"start": "cross-env node scripts/start.js",

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.6.0-nightly.20251227.2bc80795",
"version": "0.6.0",
"description": "Qwen Code",
"repository": {
"type": "git",
@@ -33,7 +33,7 @@
"dist"
],
"config": {
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.6.0-nightly.20251227.2bc80795"
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.6.0"
},
"dependencies": {
"@google/genai": "1.30.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code-core",
"version": "0.6.0-nightly.20251227.2bc80795",
"version": "0.6.0",
"description": "Qwen Code Core",
"repository": {
"type": "git",

View File

@@ -542,4 +542,206 @@ describe('OpenAIContentConverter', () => {
expect(original).toEqual(originalCopy);
});
});
describe('mergeConsecutiveAssistantMessages', () => {
it('should merge two consecutive assistant messages with string content', () => {
const request: GenerateContentParameters = {
model: 'models/test',
contents: [
{
role: 'model',
parts: [{ text: 'First part' }],
},
{
role: 'model',
parts: [{ text: 'Second part' }],
},
],
};
const messages = converter.convertGeminiRequestToOpenAI(request);
expect(messages).toHaveLength(1);
expect(messages[0].role).toBe('assistant');
const content = messages[0]
.content as OpenAI.Chat.ChatCompletionContentPart[];
expect(content).toHaveLength(2);
expect(content[0]).toEqual({ type: 'text', text: 'First part' });
expect(content[1]).toEqual({ type: 'text', text: 'Second part' });
});
it('should merge multiple consecutive assistant messages', () => {
const request: GenerateContentParameters = {
model: 'models/test',
contents: [
{
role: 'model',
parts: [{ text: 'Part 1' }],
},
{
role: 'model',
parts: [{ text: 'Part 2' }],
},
{
role: 'model',
parts: [{ text: 'Part 3' }],
},
],
};
const messages = converter.convertGeminiRequestToOpenAI(request);
expect(messages).toHaveLength(1);
expect(messages[0].role).toBe('assistant');
const content = messages[0]
.content as OpenAI.Chat.ChatCompletionContentPart[];
expect(content).toHaveLength(3);
});
it('should merge tool_calls from consecutive assistant messages', () => {
const request: GenerateContentParameters = {
model: 'models/test',
contents: [
{
role: 'model',
parts: [
{
functionCall: {
id: 'call_1',
name: 'tool_1',
args: {},
},
},
],
},
{
role: 'user',
parts: [
{
functionResponse: {
id: 'call_1',
name: 'tool_1',
response: { output: 'result_1' },
},
},
],
},
{
role: 'model',
parts: [
{
functionCall: {
id: 'call_2',
name: 'tool_2',
args: {},
},
},
],
},
{
role: 'user',
parts: [
{
functionResponse: {
id: 'call_2',
name: 'tool_2',
response: { output: 'result_2' },
},
},
],
},
],
};
const messages = converter.convertGeminiRequestToOpenAI(request);
// Should have: assistant (tool_call_1), tool (result_1), assistant (tool_call_2), tool (result_2)
expect(messages).toHaveLength(4);
expect(messages[0].role).toBe('assistant');
expect(messages[1].role).toBe('tool');
expect(messages[2].role).toBe('assistant');
expect(messages[3].role).toBe('tool');
});
it('should not merge assistant messages separated by user messages', () => {
const request: GenerateContentParameters = {
model: 'models/test',
contents: [
{
role: 'model',
parts: [{ text: 'First assistant' }],
},
{
role: 'user',
parts: [{ text: 'User message' }],
},
{
role: 'model',
parts: [{ text: 'Second assistant' }],
},
],
};
const messages = converter.convertGeminiRequestToOpenAI(request);
expect(messages).toHaveLength(3);
expect(messages[0].role).toBe('assistant');
expect(messages[1].role).toBe('user');
expect(messages[2].role).toBe('assistant');
});
it('should handle merging when one message has array content and another has string', () => {
const request: GenerateContentParameters = {
model: 'models/test',
contents: [
{
role: 'model',
parts: [{ text: 'Text part' }],
},
{
role: 'model',
parts: [{ text: 'Another text' }],
},
],
};
const messages = converter.convertGeminiRequestToOpenAI(request);
expect(messages).toHaveLength(1);
const content = messages[0]
.content as OpenAI.Chat.ChatCompletionContentPart[];
expect(Array.isArray(content)).toBe(true);
expect(content).toHaveLength(2);
});
it('should merge empty content correctly', () => {
const request: GenerateContentParameters = {
model: 'models/test',
contents: [
{
role: 'model',
parts: [{ text: 'First' }],
},
{
role: 'model',
parts: [],
},
{
role: 'model',
parts: [{ text: 'Second' }],
},
],
};
const messages = converter.convertGeminiRequestToOpenAI(request);
// Empty messages should be filtered out
expect(messages).toHaveLength(1);
const content = messages[0]
.content as OpenAI.Chat.ChatCompletionContentPart[];
expect(content).toHaveLength(2);
expect(content[0]).toEqual({ type: 'text', text: 'First' });
expect(content[1]).toEqual({ type: 'text', text: 'Second' });
});
});
});

View File

@@ -1120,12 +1120,44 @@ export class OpenAIContentConverter {
// If the last message is also an assistant message, merge them
if (lastMessage.role === 'assistant') {
// Combine content
const combinedContent = [
typeof lastMessage.content === 'string' ? lastMessage.content : '',
typeof message.content === 'string' ? message.content : '',
]
.filter(Boolean)
.join('');
const lastContent = lastMessage.content;
const currentContent = message.content;
// Determine if we should use array format (if either content is an array)
const useArrayFormat =
Array.isArray(lastContent) || Array.isArray(currentContent);
let combinedContent:
| string
| OpenAI.Chat.ChatCompletionContentPart[]
| null;
if (useArrayFormat) {
// Convert both to array format and merge
const lastParts = Array.isArray(lastContent)
? lastContent
: typeof lastContent === 'string' && lastContent
? [{ type: 'text' as const, text: lastContent }]
: [];
const currentParts = Array.isArray(currentContent)
? currentContent
: typeof currentContent === 'string' && currentContent
? [{ type: 'text' as const, text: currentContent }]
: [];
combinedContent = [
...lastParts,
...currentParts,
] as OpenAI.Chat.ChatCompletionContentPart[];
} else {
// Both are strings or null, merge as strings
const lastText = typeof lastContent === 'string' ? lastContent : '';
const currentText =
typeof currentContent === 'string' ? currentContent : '';
const mergedText = [lastText, currentText].filter(Boolean).join('');
combinedContent = mergedText || null;
}
// Combine tool calls
const lastToolCalls =
@@ -1137,14 +1169,17 @@ export class OpenAIContentConverter {
// Update the last message with combined data
(
lastMessage as OpenAI.Chat.ChatCompletionMessageParam & {
content: string | null;
content: string | OpenAI.Chat.ChatCompletionContentPart[] | null;
tool_calls?: OpenAI.Chat.ChatCompletionMessageToolCall[];
}
).content = combinedContent || null;
if (combinedToolCalls.length > 0) {
(
lastMessage as OpenAI.Chat.ChatCompletionMessageParam & {
content: string | null;
content:
| string
| OpenAI.Chat.ChatCompletionContentPart[]
| null;
tool_calls?: OpenAI.Chat.ChatCompletionMessageToolCall[];
}
).tool_calls = combinedToolCalls;

View File

@@ -169,6 +169,44 @@ describe('ShellTool', () => {
});
expect(invocation.getDescription()).not.toContain('[background]');
});
describe('is_background parameter coercion', () => {
it('should accept string "true" as boolean true', () => {
const invocation = shellTool.build({
command: 'npm run dev',
is_background: 'true' as unknown as boolean,
});
expect(invocation).toBeDefined();
expect(invocation.getDescription()).toContain('[background]');
});
it('should accept string "false" as boolean false', () => {
const invocation = shellTool.build({
command: 'npm run build',
is_background: 'false' as unknown as boolean,
});
expect(invocation).toBeDefined();
expect(invocation.getDescription()).not.toContain('[background]');
});
it('should accept string "True" as boolean true', () => {
const invocation = shellTool.build({
command: 'npm run dev',
is_background: 'True' as unknown as boolean,
});
expect(invocation).toBeDefined();
expect(invocation.getDescription()).toContain('[background]');
});
it('should accept string "False" as boolean false', () => {
const invocation = shellTool.build({
command: 'npm run build',
is_background: 'False' as unknown as boolean,
});
expect(invocation).toBeDefined();
expect(invocation.getDescription()).not.toContain('[background]');
});
});
});
describe('execute', () => {

View File

@@ -122,4 +122,91 @@ describe('SchemaValidator', () => {
};
expect(SchemaValidator.validate(schema, params)).not.toBeNull();
});
describe('boolean string coercion', () => {
const booleanSchema = {
type: 'object',
properties: {
is_background: {
type: 'boolean',
},
},
required: ['is_background'],
};
it('should coerce string "true" to boolean true', () => {
const params = { is_background: 'true' };
expect(SchemaValidator.validate(booleanSchema, params)).toBeNull();
expect(params.is_background).toBe(true);
});
it('should coerce string "True" to boolean true', () => {
const params = { is_background: 'True' };
expect(SchemaValidator.validate(booleanSchema, params)).toBeNull();
expect(params.is_background).toBe(true);
});
it('should coerce string "TRUE" to boolean true', () => {
const params = { is_background: 'TRUE' };
expect(SchemaValidator.validate(booleanSchema, params)).toBeNull();
expect(params.is_background).toBe(true);
});
it('should coerce string "false" to boolean false', () => {
const params = { is_background: 'false' };
expect(SchemaValidator.validate(booleanSchema, params)).toBeNull();
expect(params.is_background).toBe(false);
});
it('should coerce string "False" to boolean false', () => {
const params = { is_background: 'False' };
expect(SchemaValidator.validate(booleanSchema, params)).toBeNull();
expect(params.is_background).toBe(false);
});
it('should coerce string "FALSE" to boolean false', () => {
const params = { is_background: 'FALSE' };
expect(SchemaValidator.validate(booleanSchema, params)).toBeNull();
expect(params.is_background).toBe(false);
});
it('should handle nested objects with string booleans', () => {
const nestedSchema = {
type: 'object',
properties: {
options: {
type: 'object',
properties: {
enabled: { type: 'boolean' },
},
},
},
};
const params = { options: { enabled: 'true' } };
expect(SchemaValidator.validate(nestedSchema, params)).toBeNull();
expect((params.options as unknown as { enabled: boolean }).enabled).toBe(
true,
);
});
it('should not affect non-boolean strings', () => {
const mixedSchema = {
type: 'object',
properties: {
name: { type: 'string' },
is_active: { type: 'boolean' },
},
};
const params = { name: 'trueman', is_active: 'true' };
expect(SchemaValidator.validate(mixedSchema, params)).toBeNull();
expect(params.name).toBe('trueman');
expect(params.is_active).toBe(true);
});
it('should pass through actual boolean values unchanged', () => {
const params = { is_background: true };
expect(SchemaValidator.validate(booleanSchema, params)).toBeNull();
expect(params.is_background).toBe(true);
});
});
});

View File

@@ -41,14 +41,12 @@ export class SchemaValidator {
return 'Value of params must be an object';
}
const validate = ajValidator.compile(schema);
const valid = validate(data);
let valid = validate(data);
if (!valid && validate.errors) {
// Find any True or False values and lowercase them
fixBooleanCasing(data as Record<string, unknown>);
const validate = ajValidator.compile(schema);
const valid = validate(data);
// Coerce string boolean values ("true"/"false") to actual booleans
fixBooleanValues(data as Record<string, unknown>);
valid = validate(data);
if (!valid && validate.errors) {
return ajValidator.errorsText(validate.errors, { dataVar: 'params' });
}
@@ -57,13 +55,29 @@ export class SchemaValidator {
}
}
function fixBooleanCasing(data: Record<string, unknown>) {
/**
* Coerces string boolean values to actual booleans.
* This handles cases where LLMs return "true"/"false" strings instead of boolean values,
* which is common with self-hosted LLMs.
*
* Converts:
* - "true", "True", "TRUE" -> true
* - "false", "False", "FALSE" -> false
*/
function fixBooleanValues(data: Record<string, unknown>) {
for (const key of Object.keys(data)) {
if (!(key in data)) continue;
const value = data[key];
if (typeof data[key] === 'object') {
fixBooleanCasing(data[key] as Record<string, unknown>);
} else if (data[key] === 'True') data[key] = 'true';
else if (data[key] === 'False') data[key] = 'false';
if (typeof value === 'object' && value !== null) {
fixBooleanValues(value as Record<string, unknown>);
} else if (typeof value === 'string') {
const lower = value.toLowerCase();
if (lower === 'true') {
data[key] = true;
} else if (lower === 'false') {
data[key] = false;
}
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/sdk",
"version": "0.1.0",
"version": "0.1.1",
"description": "TypeScript SDK for programmatic access to qwen-code CLI",
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code-test-utils",
"version": "0.6.0-nightly.20251227.2bc80795",
"version": "0.6.0",
"private": true,
"main": "src/index.ts",
"license": "Apache-2.0",

View File

@@ -2,7 +2,7 @@
"name": "qwen-code-vscode-ide-companion",
"displayName": "Qwen Code Companion",
"description": "Enable Qwen Code with direct access to your VS Code workspace.",
"version": "0.6.0-nightly.20251227.2bc80795",
"version": "0.6.0",
"publisher": "qwenlm",
"icon": "assets/icon.png",
"repository": {